From 2454494abca90a76749acae228a4cf8ad8700873 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 17 Nov 2023 13:42:40 -0800 Subject: [PATCH 01/39] Update versions for hotfix --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 798c804d..fda53ebb 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ Rare Disease Project ubc.pavlab rdp - 1.5.7 + 1.5.8 Registry for model organism/system researchers, developed for the Canadian Rare Disease Models & Mechanisms Network. From acdc2c930ebf6d78b42f5799c1be73b5e01e1a51 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 17 Nov 2023 11:10:36 -0800 Subject: [PATCH 02/39] Improve the Gene2Go parser memory efficiency Finish early if all the taxa we needed to parse were seen already. Do not produce records for inactive taxa. --- docs/customization.md | 2 + .../pavlab/rdp/services/GOServiceImpl.java | 11 +- .../ubc/pavlab/rdp/util/Gene2GoParser.java | 101 +++++++++++------- .../rdp/services/GOServiceImplTest.java | 11 +- .../util/Gene2GoParserIntegrationTest.java | 37 +++++++ 5 files changed, 122 insertions(+), 40 deletions(-) create mode 100644 src/test/java/ubc/pavlab/rdp/util/Gene2GoParserIntegrationTest.java diff --git a/docs/customization.md b/docs/customization.md index 1eba2ac6..d8d6767f 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -40,6 +40,8 @@ GO terms, on the other hand, are obtained from Ontobee: rdp.settings.cache.term-file=http://purl.obolibrary.org/obo/go.obo ``` +gene2go associations will only be populated for active taxa (new in 1.5.8). + ## Gene Tiers Users' genes are categorized in tiers based on their familiarity and experience with the gene. This is explained in diff --git a/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java index fc519621..a1c27dd1 100644 --- a/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java @@ -49,6 +49,9 @@ public class GOServiceImpl implements GOService, InitializingBean { ANCESTORS_CACHE_NAME = "ubc.pavlab.rdp.services.GOService.ancestors", DESCENDANTS_CACHE_NAME = "ubc.pavlab.rdp.services.GOService.descendants"; + @Autowired + private TaxonService taxonService; + @Autowired private GeneOntologyTermInfoRepository goRepository; @@ -58,9 +61,6 @@ public class GOServiceImpl implements GOService, InitializingBean { @Autowired private OBOParser oboParser; - @Autowired - private Gene2GoParser gene2GoParser; - @Autowired private CacheManager cacheManager; @@ -157,6 +157,11 @@ public void updateGoTerms() { log.info( String.format( "Loading gene2go annotations from: %s.", cacheSettings.getAnnotationFile() ) ); + Set activeTaxa = taxonService.findByActiveTrue().stream() + .map( Taxon::getId ) + .collect( Collectors.toSet() ); + Gene2GoParser gene2GoParser = new Gene2GoParser( activeTaxa ); + Collection records; try { records = gene2GoParser.parse( new GZIPInputStream( cacheSettings.getAnnotationFile().getInputStream() ) ); diff --git a/src/main/java/ubc/pavlab/rdp/util/Gene2GoParser.java b/src/main/java/ubc/pavlab/rdp/util/Gene2GoParser.java index 824ad795..afc24da7 100644 --- a/src/main/java/ubc/pavlab/rdp/util/Gene2GoParser.java +++ b/src/main/java/ubc/pavlab/rdp/util/Gene2GoParser.java @@ -1,16 +1,17 @@ package ubc.pavlab.rdp.util; +import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Value; import lombok.extern.apachecommons.CommonsLog; import org.apache.commons.lang3.ArrayUtils; -import org.springframework.stereotype.Component; +import org.apache.commons.lang3.time.StopWatch; -import java.io.*; -import java.text.MessageFormat; -import java.util.Collection; -import java.util.Objects; -import java.util.stream.Collectors; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.LineNumberReader; +import java.util.*; /** * Read in the Gene2Go file provided by NCBI. @@ -18,35 +19,34 @@ * Created by mjacobson on 17/01/18. */ @CommonsLog -@Component public class Gene2GoParser { private static final String TAXON_ID_FIELD = "#tax_id", GENE_ID_FIELD = "GeneID", GO_ID_FIELD = "GO_ID"; private static final String[] EXPECTED_FIELDS = { TAXON_ID_FIELD, GENE_ID_FIELD, GO_ID_FIELD }; + private static final int + TAXON_ID_INDEX = ArrayUtils.indexOf( EXPECTED_FIELDS, TAXON_ID_FIELD ), + GENE_ID_INDEX = ArrayUtils.indexOf( EXPECTED_FIELDS, GENE_ID_FIELD ), + GO_ID_INDEX = ArrayUtils.indexOf( EXPECTED_FIELDS, GO_ID_FIELD ); - @Data - @AllArgsConstructor - public static class Record { - private Integer taxonId; - private Integer geneId; - private String goId; + private final Set retainedTaxa; - public static Record parseLine( String line, String[] headerFields, int lineNumber ) throws UncheckedParseException { - String[] values = line.split( "\t" ); - if ( values.length < headerFields.length ) { - throw new UncheckedParseException( MessageFormat.format( "Unexpected number of parts in: {0}", line ), lineNumber ); - } - try { - return new Record( Integer.valueOf( values[ArrayUtils.indexOf( headerFields, TAXON_ID_FIELD )] ), - Integer.valueOf( values[ArrayUtils.indexOf( headerFields, GENE_ID_FIELD )] ), - values[ArrayUtils.indexOf( headerFields, GO_ID_FIELD )] ); - } catch ( NumberFormatException e ) { - throw new UncheckedParseException( MessageFormat.format( "Could not parse number for: {0}.", line ), lineNumber, e ); - } - } + /** + * @param retainedTaxa a set of taxa to retain from the gene2go input, or null to ignore + */ + public Gene2GoParser( Set retainedTaxa ) { + this.retainedTaxa = retainedTaxa; + } + + @Value + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class Record { + int taxonId; + int geneId; + String goId; } public Collection parse( InputStream input ) throws ParseException, IOException { + StopWatch timer = StopWatch.createStarted(); try ( LineNumberReader br = new LineNumberReader( new InputStreamReader( input ) ) ) { String headerLine = br.readLine(); @@ -58,19 +58,48 @@ public Collection parse( InputStream input ) throws ParseException, IOEx for ( String field : EXPECTED_FIELDS ) { if ( !ArrayUtils.contains( headerFields, field ) ) { - throw new ParseException( MessageFormat.format( "Unexpected header line: {0}.", headerLine ), br.getLineNumber() ); + throw new ParseException( String.format( "Unexpected header line: %s", headerLine ), br.getLineNumber() ); } } - try { - return br.lines() - .map( line -> Record.parseLine( line, headerFields, br.getLineNumber() ) ) - .collect( Collectors.toList() ); - } catch ( UncheckedIOException ioe ) { - throw ioe.getCause(); - } catch ( UncheckedParseException e ) { - throw e.getCause(); + String line; + Set seenTaxa = new HashSet<>(); + List records = new ArrayList<>(); + while ( ( line = br.readLine() ) != null ) { + Record r; + int lineNumber = br.getLineNumber(); + int taxonId, geneId; + String goId; + String[] values = line.split( "\t" ); + if ( values.length < headerFields.length ) { + throw new ParseException( String.format( "Unexpected number of parts in: %s", line ), lineNumber ); + } + try { + taxonId = Integer.parseInt( values[TAXON_ID_INDEX] ); + seenTaxa.add( taxonId ); + if ( retainedTaxa != null && !retainedTaxa.contains( taxonId ) ) { + // we've seen all the taxa that we needed to, terminate + if ( seenTaxa.containsAll( retainedTaxa ) ) { + log.debug( "All taxa we needed were parsed, terminating early!" ); + break; + } + continue; + } else { + geneId = Integer.parseInt( values[GENE_ID_INDEX] ); + goId = values[GO_ID_INDEX]; + r = new Record( taxonId, geneId, goId ); + } + } catch ( NumberFormatException e ) { + throw new ParseException( String.format( "Could not parse number for: %s", line ), lineNumber, e ); + } finally { + if ( ( lineNumber + 1 ) % 1000000 == 0 ) { + log.debug( String.format( "Parsed %d line from (%d line/s)", + lineNumber + 1, (int) ( 1000.0 * ( lineNumber + 1 ) / timer.getTime() ) ) ); + } + } + records.add( r ); } + return records; } } } diff --git a/src/test/java/ubc/pavlab/rdp/services/GOServiceImplTest.java b/src/test/java/ubc/pavlab/rdp/services/GOServiceImplTest.java index 4479bf36..833d9969 100644 --- a/src/test/java/ubc/pavlab/rdp/services/GOServiceImplTest.java +++ b/src/test/java/ubc/pavlab/rdp/services/GOServiceImplTest.java @@ -7,6 +7,7 @@ import org.mockito.internal.util.collections.Sets; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.cache.CacheManager; import org.springframework.cache.concurrent.ConcurrentMapCacheManager; import org.springframework.context.annotation.Bean; @@ -28,6 +29,9 @@ import static java.util.function.Function.identity; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.internal.verification.VerificationModeFactory.times; import static ubc.pavlab.rdp.util.TestUtils.*; /** @@ -58,7 +62,7 @@ public GOService goService() { @Bean public Gene2GoParser gene2GoParser() { - return new Gene2GoParser(); + return new Gene2GoParser( null ); } @Bean @@ -80,6 +84,9 @@ public CacheManager cacheManager() { @Autowired private GOService goService; + @MockBean + public TaxonService taxonService; + private Taxon taxon; private Map genes; private Map terms; @@ -95,6 +102,7 @@ public void setUp() { // T2[G2] T3[G1] T5[G1] taxon = createTaxon( 1 ); + when( taxonService.findByActiveTrue() ).thenReturn( Collections.singleton( taxon ) ); genes = new HashMap<>(); @@ -456,5 +464,6 @@ public void getTerm_whenNullId_thenReturnNull() { public void updateGoTerms() { goService.updateGoTerms(); assertThat( goService.getTerm( "GO:0000001" ) ).isNotNull(); + verify( taxonService, times( 2 ) ).findByActiveTrue(); } } \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/util/Gene2GoParserIntegrationTest.java b/src/test/java/ubc/pavlab/rdp/util/Gene2GoParserIntegrationTest.java new file mode 100644 index 00000000..ff8b6900 --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/util/Gene2GoParserIntegrationTest.java @@ -0,0 +1,37 @@ +package ubc.pavlab.rdp.util; + +import org.junit.Test; +import org.springframework.core.io.UrlResource; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.zip.GZIPInputStream; + +import static org.junit.Assume.assumeNoException; + +public class Gene2GoParserIntegrationTest { + + /** + * This test can be lengthy! + */ + @Test + public void parse_withOnlineFile_thenSucceeds() throws ParseException { + Gene2GoParser parser = new Gene2GoParser( Collections.singleton( 9606 ) ); + try ( InputStream is = new GZIPInputStream( new UrlResource( "ftp://ftp.ncbi.nlm.nih.gov/gene/DATA/gene2go.gz" ).getInputStream() ) ) { + parser.parse( is ); + } catch ( IOException e ) { + assumeNoException( e ); + } + } + + @Test + public void parse_withOnlineFile_whenFileIsEmpty_thenSkipTheWholeFile() throws ParseException { + Gene2GoParser parser = new Gene2GoParser( Collections.emptySet() ); + try ( InputStream is = new GZIPInputStream( new UrlResource( "ftp://ftp.ncbi.nlm.nih.gov/gene/DATA/gene2go.gz" ).getInputStream() ) ) { + parser.parse( is ); + } catch ( IOException e ) { + assumeNoException( e ); + } + } +} \ No newline at end of file From 885996d2814d0f08ef7b6807669551c36285ec52 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 17 Nov 2023 13:47:05 -0800 Subject: [PATCH 03/39] Update dependencies --- pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index fda53ebb..c078a241 100644 --- a/pom.xml +++ b/pom.xml @@ -140,7 +140,7 @@ commons-io commons-io - 2.13.0 + 2.15.0 @@ -159,7 +159,7 @@ nl.basjes.parse.useragent yauaa - 7.22.0 + 7.23.0 @@ -205,7 +205,7 @@ org.json json - 20230618 + 20231013 test From bc7508a5ca2811c1a5d99ea38b1f6f32738265be Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 17 Nov 2023 13:49:09 -0800 Subject: [PATCH 04/39] Update frontend dependencies --- src/main/resources/package-lock.json | 1052 ++++++++++++++------------ src/main/resources/package.json | 10 +- 2 files changed, 554 insertions(+), 508 deletions(-) diff --git a/src/main/resources/package-lock.json b/src/main/resources/package-lock.json index 786dc502..926be11d 100644 --- a/src/main/resources/package-lock.json +++ b/src/main/resources/package-lock.json @@ -17,18 +17,18 @@ "popper.js": "~1.16.1" }, "devDependencies": { - "@babel/core": "^7.22.10", - "@babel/preset-env": "^7.22.10", + "@babel/core": "^7.23.3", + "@babel/preset-env": "^7.23.3", "babel-loader": "^8.3.0", "css-loader": "^6.8.1", - "postcss": "^8.4.28", + "postcss": "^8.4.31", "postcss-loader": "^7.3.3", "postcss-preset-env": "^7.8.3", - "sass": "^1.66.1", + "sass": "^1.69.5", "sass-loader": "^13.3.2", "source-map-loader": "^4.0.1", "style-loader": "^3.3.3", - "webpack": "^5.88.2", + "webpack": "^5.89.0", "webpack-cli": "^4.10.0" } }, @@ -46,12 +46,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz", - "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dev": true, "dependencies": { - "@babel/highlight": "^7.22.10", + "@babel/highlight": "^7.22.13", "chalk": "^2.4.2" }, "engines": { @@ -59,34 +59,34 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz", - "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz", + "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.10.tgz", - "integrity": "sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz", + "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-compilation-targets": "^7.22.10", - "@babel/helper-module-transforms": "^7.22.9", - "@babel/helpers": "^7.22.10", - "@babel/parser": "^7.22.10", - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.10", - "@babel/types": "^7.22.10", - "convert-source-map": "^1.7.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.3", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.3", + "@babel/types": "^7.23.3", + "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.2", + "json5": "^2.2.3", "semver": "^6.3.1" }, "engines": { @@ -98,12 +98,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.23.3", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -125,25 +125,25 @@ } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.10.tgz", - "integrity": "sha512-Av0qubwDQxC56DoUReVDeLfMEjYYSN1nZrTUrWkXd7hpU73ymRANkbuDm3yni9npkn+RXy9nNbEJZEzXr7xrfQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", + "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", "dev": true, "dependencies": { - "@babel/types": "^7.22.10" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz", - "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", "dev": true, "dependencies": { "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.5", + "@babel/helper-validator-option": "^7.22.15", "browserslist": "^4.21.9", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -153,15 +153,15 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.10.tgz", - "integrity": "sha512-5IBb77txKYQPpOEdUdIhBx8VrZyDCQ+H82H0+5dX1TmuscP5vJKEE3cKurjtIw/vFwzbVH48VweE78kVDBrqjA==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz", + "integrity": "sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-environment-visitor": "^7.22.5", "@babel/helper-function-name": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.5", + "@babel/helper-member-expression-to-functions": "^7.22.15", "@babel/helper-optimise-call-expression": "^7.22.5", "@babel/helper-replace-supers": "^7.22.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", @@ -176,9 +176,9 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.9.tgz", - "integrity": "sha512-+svjVa/tFwsNSG4NEy1h85+HQ5imbT92Q5/bgtS7P0GTQlP8WuFdqsiABmQouhiFGyV66oGxZFpeYHza1rNsKw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", + "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", @@ -193,9 +193,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.2.tgz", - "integrity": "sha512-k0qnnOqHn5dK9pZpfD5XXZ9SojAITdCKRn2Lp6rnDGzIbaP0rHyMPk/4wsSxVBVz4RfN0q6VpXWP2pDGIoQ7hw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.3.tgz", + "integrity": "sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -209,22 +209,22 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -243,40 +243,40 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz", - "integrity": "sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", + "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", - "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz", - "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", "@babel/helper-simple-access": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.5" + "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -307,14 +307,14 @@ } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.9.tgz", - "integrity": "sha512-8WWC4oR4Px+tr+Fp0X3RHDVfINGpF3ad1HIbrc8A77epiR6eMMc6jsgozkzT2uDiOOdoS9cLIQ+XD2XvI2WSmQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", + "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-wrap-function": "^7.22.9" + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-wrap-function": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -324,13 +324,13 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.9.tgz", - "integrity": "sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", + "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-member-expression-to-functions": "^7.22.15", "@babel/helper-optimise-call-expression": "^7.22.5" }, "engines": { @@ -386,58 +386,58 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", - "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.10.tgz", - "integrity": "sha512-OnMhjWjuGYtdoO3FmsEFWvBStBAe2QOgwOLsLNDjN+aaiMD8InJk1/O3HSD8lkqTjCgg5YI34Tz15KNNA3p+nQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", + "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", "dev": true, "dependencies": { "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.10" + "@babel/template": "^7.22.15", + "@babel/types": "^7.22.19" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.10.tgz", - "integrity": "sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.10", - "@babel/types": "^7.22.10" + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz", - "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, @@ -446,9 +446,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.10.tgz", - "integrity": "sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -458,9 +458,9 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz", - "integrity": "sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", + "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -473,14 +473,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.5.tgz", - "integrity": "sha512-31Bb65aZaUwqCbWMnZPduIZxCBngHFlzyN6Dq6KAJjtx+lx6ohKHubc61OomYi7XwVD4Ol0XCVz4h+pYFR048g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", + "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.5" + "@babel/plugin-transform-optional-chaining": "^7.23.3" }, "engines": { "node": ">=6.9.0" @@ -489,6 +489,22 @@ "@babel/core": "^7.13.0" } }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.3.tgz", + "integrity": "sha512-XaJak1qcityzrX0/IU5nKHb34VaibwP3saKqG6a/tppelgllOH13LUann4ZCIBcVOeE6H18K4Vx9QKkVww3z/w==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", @@ -565,9 +581,9 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz", - "integrity": "sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", + "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -580,9 +596,9 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz", - "integrity": "sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz", + "integrity": "sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -737,9 +753,9 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz", - "integrity": "sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", + "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -752,14 +768,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.10.tgz", - "integrity": "sha512-eueE8lvKVzq5wIObKK/7dvoeKJ+xc6TvRn6aysIjS6pSCeLy7S/eVi7pEQknZqyqvzaNKdDtem8nUNTBgDVR2g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.3.tgz", + "integrity": "sha512-59GsVNavGxAXCDDbakWSMJhajASb4kBCqDjqJsv+p5nKdbz7istmZ3HrX3L2LuiI80+zsOADCvooqQH3qGCucQ==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.9", + "@babel/helper-remap-async-to-generator": "^7.22.20", "@babel/plugin-syntax-async-generators": "^7.8.4" }, "engines": { @@ -770,14 +786,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz", - "integrity": "sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", + "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.5" + "@babel/helper-remap-async-to-generator": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -787,9 +803,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz", - "integrity": "sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", + "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -802,9 +818,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.10.tgz", - "integrity": "sha512-1+kVpGAOOI1Albt6Vse7c8pHzcZQdQKW+wJH+g8mCaszOdDVwRXa/slHPqIw+oJAJANTKDMuM2cBdV0Dg618Vg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.3.tgz", + "integrity": "sha512-QPZxHrThbQia7UdvfpaRRlq/J9ciz1J4go0k+lPBXbgaNeY7IQrBj/9ceWjvMMI07/ZBzHl/F0R/2K0qH7jCVw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -817,12 +833,12 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz", - "integrity": "sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", + "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -833,12 +849,12 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.5.tgz", - "integrity": "sha512-SPToJ5eYZLxlnp1UzdARpOGeC2GbHvr9d/UV0EukuVx8atktg194oe+C5BqQ8jRTkgLRVOPYeXRSBg1IlMoVRA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.3.tgz", + "integrity": "sha512-PENDVxdr7ZxKPyi5Ffc0LjXdnJyrJxyqF5T5YjlVg4a0VFfQHW0r8iAtRiDXkfHlu1wwcvdtnndGYIeJLSuRMQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-class-static-block": "^7.14.5" }, @@ -850,18 +866,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.6.tgz", - "integrity": "sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.3.tgz", + "integrity": "sha512-FGEQmugvAEu2QtgtU0uTASXevfLMFfBeVCIIdcQhn/uBQsMTjBajdnAtanQlOcuihWh10PZ7+HWvc7NtBwP74w==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-optimise-call-expression": "^7.22.5", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.20", "@babel/helper-split-export-declaration": "^7.22.6", "globals": "^11.1.0" }, @@ -873,13 +889,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz", - "integrity": "sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", + "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.5" + "@babel/template": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -889,9 +905,9 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.10.tgz", - "integrity": "sha512-dPJrL0VOyxqLM9sritNbMSGx/teueHF/htMKrPT7DNxccXxRDPYqlgPFFdr8u+F+qUZOkZoXue/6rL5O5GduEw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", + "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -904,12 +920,12 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz", - "integrity": "sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", + "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -920,9 +936,9 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz", - "integrity": "sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", + "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -935,9 +951,9 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.5.tgz", - "integrity": "sha512-0MC3ppTB1AMxd8fXjSrbPa7LT9hrImt+/fcj+Pg5YMD7UQyWp/02+JWpdnCymmsXwIx5Z+sYn1bwCn4ZJNvhqQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.3.tgz", + "integrity": "sha512-vTG+cTGxPFou12Rj7ll+eD5yWeNl5/8xvQvF08y5Gv3v4mZQoyFf8/n9zg4q5vvCWt5jmgymfzMAldO7orBn7A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -951,12 +967,12 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz", - "integrity": "sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", + "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", "dev": true, "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.5", + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -967,9 +983,9 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.5.tgz", - "integrity": "sha512-X4hhm7FRnPgd4nDA4b/5V280xCx6oL7Oob5+9qVS5C13Zq4bh1qq7LU0GgRU6b5dBWBvhGaXYVB4AcN6+ol6vg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.3.tgz", + "integrity": "sha512-yCLhW34wpJWRdTxxWtFZASJisihrfyMOTOQexhVzA78jlU+dH7Dw+zQgcPepQ5F3C6bAIiblZZ+qBggJdHiBAg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -983,9 +999,9 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.5.tgz", - "integrity": "sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.3.tgz", + "integrity": "sha512-X8jSm8X1CMwxmK878qsUGJRmbysKNbdpTv/O1/v0LuY/ZkZrng5WYiekYSdg9m09OTmDDUWeEDsTE+17WYbAZw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -998,13 +1014,13 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz", - "integrity": "sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", + "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1015,9 +1031,9 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.5.tgz", - "integrity": "sha512-DuCRB7fu8MyTLbEQd1ew3R85nx/88yMoqo2uPSjevMj3yoN7CDM8jkgrY0wmVxfJZyJ/B9fE1iq7EQppWQmR5A==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.3.tgz", + "integrity": "sha512-H9Ej2OiISIZowZHaBwF0tsJOih1PftXJtE8EWqlEIwpc7LMTGq0rPOrywKLQ4nefzx8/HMR0D3JGXoMHYvhi0A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1031,9 +1047,9 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz", - "integrity": "sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", + "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1046,9 +1062,9 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.5.tgz", - "integrity": "sha512-MQQOUW1KL8X0cDWfbwYP+TbVbZm16QmQXJQ+vndPtH/BoO0lOKpVoEDMI7+PskYxH+IiE0tS8xZye0qr1lGzSA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.3.tgz", + "integrity": "sha512-+pD5ZbxofyOygEp+zZAfujY2ShNCXRpDRIPOiBmTO693hhyOEteZgl876Xs9SAHPQpcV0vz8LvA/T+w8AzyX8A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1062,9 +1078,9 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz", - "integrity": "sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", + "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1077,12 +1093,12 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz", - "integrity": "sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", + "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1093,12 +1109,12 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.5.tgz", - "integrity": "sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", + "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-simple-access": "^7.22.5" }, @@ -1110,15 +1126,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.5.tgz", - "integrity": "sha512-emtEpoaTMsOs6Tzz+nbmcePl6AKVtS1yC4YNAeMun9U8YCsgadPNxnOPQ8GhHFB2qdx+LZu9LgoC0Lthuu05DQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", + "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", "dev": true, "dependencies": { "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5" + "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -1128,12 +1144,12 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz", - "integrity": "sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", + "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1160,9 +1176,9 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz", - "integrity": "sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", + "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1175,9 +1191,9 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.5.tgz", - "integrity": "sha512-6CF8g6z1dNYZ/VXok5uYkkBBICHZPiGEl7oDnAx2Mt1hlHVHOSIKWJaXHjQJA5VB43KZnXZDIexMchY4y2PGdA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.3.tgz", + "integrity": "sha512-xzg24Lnld4DYIdysyf07zJ1P+iIfJpxtVFOzX4g+bsJ3Ng5Le7rXx9KwqKzuyaUeRnt+I1EICwQITqc0E2PmpA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1191,9 +1207,9 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.5.tgz", - "integrity": "sha512-NbslED1/6M+sXiwwtcAB/nieypGw02Ejf4KtDeMkCEpP6gWFMX1wI9WKYua+4oBneCCEmulOkRpwywypVZzs/g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.3.tgz", + "integrity": "sha512-s9GO7fIBi/BLsZ0v3Rftr6Oe4t0ctJ8h4CCXfPoEJwmvAPMyNrfkOOJzm6b9PX9YXcCJWWQd/sBF/N26eBiMVw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1207,16 +1223,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.5.tgz", - "integrity": "sha512-Kk3lyDmEslH9DnvCDA1s1kkd3YWQITiBOHngOtDL9Pt6BZjzqb6hiOlb8VfjiiQJ2unmegBqZu0rx5RxJb5vmQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.3.tgz", + "integrity": "sha512-VxHt0ANkDmu8TANdE9Kc0rndo/ccsmfe2Cx2y5sI4hu3AukHQ5wAu4cM7j3ba8B9548ijVyclBU+nuDQftZsog==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.5", + "@babel/compat-data": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.22.5" + "@babel/plugin-transform-parameters": "^7.23.3" }, "engines": { "node": ">=6.9.0" @@ -1226,13 +1242,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz", - "integrity": "sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", + "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5" + "@babel/helper-replace-supers": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -1242,9 +1258,9 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.5.tgz", - "integrity": "sha512-pH8orJahy+hzZje5b8e2QIlBWQvGpelS76C63Z+jhZKsmzfNaPQ+LaW6dcJ9bxTpo1mtXbgHwy765Ro3jftmUg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.3.tgz", + "integrity": "sha512-LxYSb0iLjUamfm7f1D7GpiS4j0UAC8AOiehnsGAP8BEsIX8EOi3qV6bbctw8M7ZvLtcoZfZX5Z7rN9PlWk0m5A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1258,9 +1274,9 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.10.tgz", - "integrity": "sha512-MMkQqZAZ+MGj+jGTG3OTuhKeBpNcO+0oCEbrGNEaOmiEn+1MzRyQlYsruGiU8RTK3zV6XwrVJTmwiDOyYK6J9g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.3.tgz", + "integrity": "sha512-zvL8vIfIUgMccIAK1lxjvNv572JHFJIKb4MWBz5OGdBQA0fB0Xluix5rmOby48exiJc987neOmP/m9Fnpkz3Tg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1275,9 +1291,9 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.5.tgz", - "integrity": "sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", + "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1290,12 +1306,12 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz", - "integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", + "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1306,13 +1322,13 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.5.tgz", - "integrity": "sha512-/9xnaTTJcVoBtSSmrVyhtSvO3kbqS2ODoh2juEU72c3aYonNF0OMGiaz2gjukyKM2wBBYJP38S4JiE0Wfb5VMQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.3.tgz", + "integrity": "sha512-a5m2oLNFyje2e/rGKjVfAELTVI5mbA0FeZpBnkOWWV7eSmKQ+T/XW0Vf+29ScLzSxX+rnsarvU0oie/4m6hkxA==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, @@ -1324,9 +1340,9 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz", - "integrity": "sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", + "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1339,9 +1355,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz", - "integrity": "sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", + "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1355,9 +1371,9 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz", - "integrity": "sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", + "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1370,9 +1386,9 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz", - "integrity": "sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", + "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1385,9 +1401,9 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz", - "integrity": "sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", + "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1401,9 +1417,9 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz", - "integrity": "sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", + "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1416,9 +1432,9 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz", - "integrity": "sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", + "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1431,9 +1447,9 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz", - "integrity": "sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", + "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1446,9 +1462,9 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz", - "integrity": "sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", + "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1461,12 +1477,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz", - "integrity": "sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", + "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1477,12 +1493,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz", - "integrity": "sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", + "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1493,12 +1509,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz", - "integrity": "sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", + "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1509,25 +1525,26 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.10.tgz", - "integrity": "sha512-riHpLb1drNkpLlocmSyEg4oYJIQFeXAK/d7rI6mbD0XsvoTOOweXDmQPG/ErxsEhWk3rl3Q/3F6RFQlVFS8m0A==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.3.tgz", + "integrity": "sha512-ovzGc2uuyNfNAs/jyjIGxS8arOHS5FENZaNn4rtE7UdKMMkqHCvboHfcuhWLZNX5cB44QfcGNWjaevxMzzMf+Q==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-compilation-targets": "^7.22.10", + "@babel/compat-data": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.5", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.5", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.5", + "@babel/helper-validator-option": "^7.22.15", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.3", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.22.5", - "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-assertions": "^7.23.3", + "@babel/plugin-syntax-import-attributes": "^7.23.3", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", @@ -1539,59 +1556,58 @@ "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.22.5", - "@babel/plugin-transform-async-generator-functions": "^7.22.10", - "@babel/plugin-transform-async-to-generator": "^7.22.5", - "@babel/plugin-transform-block-scoped-functions": "^7.22.5", - "@babel/plugin-transform-block-scoping": "^7.22.10", - "@babel/plugin-transform-class-properties": "^7.22.5", - "@babel/plugin-transform-class-static-block": "^7.22.5", - "@babel/plugin-transform-classes": "^7.22.6", - "@babel/plugin-transform-computed-properties": "^7.22.5", - "@babel/plugin-transform-destructuring": "^7.22.10", - "@babel/plugin-transform-dotall-regex": "^7.22.5", - "@babel/plugin-transform-duplicate-keys": "^7.22.5", - "@babel/plugin-transform-dynamic-import": "^7.22.5", - "@babel/plugin-transform-exponentiation-operator": "^7.22.5", - "@babel/plugin-transform-export-namespace-from": "^7.22.5", - "@babel/plugin-transform-for-of": "^7.22.5", - "@babel/plugin-transform-function-name": "^7.22.5", - "@babel/plugin-transform-json-strings": "^7.22.5", - "@babel/plugin-transform-literals": "^7.22.5", - "@babel/plugin-transform-logical-assignment-operators": "^7.22.5", - "@babel/plugin-transform-member-expression-literals": "^7.22.5", - "@babel/plugin-transform-modules-amd": "^7.22.5", - "@babel/plugin-transform-modules-commonjs": "^7.22.5", - "@babel/plugin-transform-modules-systemjs": "^7.22.5", - "@babel/plugin-transform-modules-umd": "^7.22.5", + "@babel/plugin-transform-arrow-functions": "^7.23.3", + "@babel/plugin-transform-async-generator-functions": "^7.23.3", + "@babel/plugin-transform-async-to-generator": "^7.23.3", + "@babel/plugin-transform-block-scoped-functions": "^7.23.3", + "@babel/plugin-transform-block-scoping": "^7.23.3", + "@babel/plugin-transform-class-properties": "^7.23.3", + "@babel/plugin-transform-class-static-block": "^7.23.3", + "@babel/plugin-transform-classes": "^7.23.3", + "@babel/plugin-transform-computed-properties": "^7.23.3", + "@babel/plugin-transform-destructuring": "^7.23.3", + "@babel/plugin-transform-dotall-regex": "^7.23.3", + "@babel/plugin-transform-duplicate-keys": "^7.23.3", + "@babel/plugin-transform-dynamic-import": "^7.23.3", + "@babel/plugin-transform-exponentiation-operator": "^7.23.3", + "@babel/plugin-transform-export-namespace-from": "^7.23.3", + "@babel/plugin-transform-for-of": "^7.23.3", + "@babel/plugin-transform-function-name": "^7.23.3", + "@babel/plugin-transform-json-strings": "^7.23.3", + "@babel/plugin-transform-literals": "^7.23.3", + "@babel/plugin-transform-logical-assignment-operators": "^7.23.3", + "@babel/plugin-transform-member-expression-literals": "^7.23.3", + "@babel/plugin-transform-modules-amd": "^7.23.3", + "@babel/plugin-transform-modules-commonjs": "^7.23.3", + "@babel/plugin-transform-modules-systemjs": "^7.23.3", + "@babel/plugin-transform-modules-umd": "^7.23.3", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.22.5", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.5", - "@babel/plugin-transform-numeric-separator": "^7.22.5", - "@babel/plugin-transform-object-rest-spread": "^7.22.5", - "@babel/plugin-transform-object-super": "^7.22.5", - "@babel/plugin-transform-optional-catch-binding": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.10", - "@babel/plugin-transform-parameters": "^7.22.5", - "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/plugin-transform-private-property-in-object": "^7.22.5", - "@babel/plugin-transform-property-literals": "^7.22.5", - "@babel/plugin-transform-regenerator": "^7.22.10", - "@babel/plugin-transform-reserved-words": "^7.22.5", - "@babel/plugin-transform-shorthand-properties": "^7.22.5", - "@babel/plugin-transform-spread": "^7.22.5", - "@babel/plugin-transform-sticky-regex": "^7.22.5", - "@babel/plugin-transform-template-literals": "^7.22.5", - "@babel/plugin-transform-typeof-symbol": "^7.22.5", - "@babel/plugin-transform-unicode-escapes": "^7.22.10", - "@babel/plugin-transform-unicode-property-regex": "^7.22.5", - "@babel/plugin-transform-unicode-regex": "^7.22.5", - "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.23.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.3", + "@babel/plugin-transform-numeric-separator": "^7.23.3", + "@babel/plugin-transform-object-rest-spread": "^7.23.3", + "@babel/plugin-transform-object-super": "^7.23.3", + "@babel/plugin-transform-optional-catch-binding": "^7.23.3", + "@babel/plugin-transform-optional-chaining": "^7.23.3", + "@babel/plugin-transform-parameters": "^7.23.3", + "@babel/plugin-transform-private-methods": "^7.23.3", + "@babel/plugin-transform-private-property-in-object": "^7.23.3", + "@babel/plugin-transform-property-literals": "^7.23.3", + "@babel/plugin-transform-regenerator": "^7.23.3", + "@babel/plugin-transform-reserved-words": "^7.23.3", + "@babel/plugin-transform-shorthand-properties": "^7.23.3", + "@babel/plugin-transform-spread": "^7.23.3", + "@babel/plugin-transform-sticky-regex": "^7.23.3", + "@babel/plugin-transform-template-literals": "^7.23.3", + "@babel/plugin-transform-typeof-symbol": "^7.23.3", + "@babel/plugin-transform-unicode-escapes": "^7.23.3", + "@babel/plugin-transform-unicode-property-regex": "^7.23.3", + "@babel/plugin-transform-unicode-regex": "^7.23.3", + "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", "@babel/preset-modules": "0.1.6-no-external-plugins", - "@babel/types": "^7.22.10", - "babel-plugin-polyfill-corejs2": "^0.4.5", - "babel-plugin-polyfill-corejs3": "^0.8.3", - "babel-plugin-polyfill-regenerator": "^0.5.2", + "babel-plugin-polyfill-corejs2": "^0.4.6", + "babel-plugin-polyfill-corejs3": "^0.8.5", + "babel-plugin-polyfill-regenerator": "^0.5.3", "core-js-compat": "^3.31.0", "semver": "^6.3.1" }, @@ -1623,9 +1639,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz", - "integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz", + "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==", "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" @@ -1635,33 +1651,33 @@ } }, "node_modules/@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.10.tgz", - "integrity": "sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.10", - "@babel/types": "^7.22.10", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -1670,13 +1686,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.10.tgz", - "integrity": "sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -2021,9 +2037,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2031,9 +2047,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.44.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.2.tgz", - "integrity": "sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg==", + "version": "8.44.7", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.7.tgz", + "integrity": "sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==", "dev": true, "dependencies": { "@types/estree": "*", @@ -2041,9 +2057,9 @@ } }, "node_modules/@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "dependencies": { "@types/eslint": "*", @@ -2051,22 +2067,25 @@ } }, "node_modules/@types/estree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, "node_modules/@types/node": { - "version": "20.5.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.3.tgz", - "integrity": "sha512-ITI7rbWczR8a/S6qjAW7DMqxqFMjjTo61qZVWJ1ubPvbIQsL5D/TvwjYEalM8Kthpe3hTzOGrF2TGbAu2uyqeA==", - "dev": true + "version": "20.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.1.tgz", + "integrity": "sha512-HhmzZh5LSJNS5O8jQKpJ/3ZcrrlG6L70hpGqMIAoM9YVD0YBRNWYsfwcXq8VnSjlNpCpgLzMXdiPo+dxcvSmiA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", @@ -2269,9 +2288,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -2351,9 +2370,9 @@ "dev": true }, "node_modules/autoprefixer": { - "version": "10.4.15", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", - "integrity": "sha512-KCuPB8ZCIqFdA4HwKXsvz7j6gvSDNhDP7WnUjBleRkKjPdvCmHFuQ77ocavI8FT6NdvlBnE2UFr2H4Mycn8Vew==", + "version": "10.4.16", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", + "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", "dev": true, "funding": [ { @@ -2371,8 +2390,8 @@ ], "dependencies": { "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001520", - "fraction.js": "^4.2.0", + "caniuse-lite": "^1.0.30001538", + "fraction.js": "^4.3.6", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", "postcss-value-parser": "^4.2.0" @@ -2407,13 +2426,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.5.tgz", - "integrity": "sha512-19hwUH5FKl49JEsvyTcoHakh6BE0wgXLLptIyKZ3PijHc/Ci521wygORCUCCred+E/twuqRyAkE02BAWPmsHOg==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.6.tgz", + "integrity": "sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==", "dev": true, "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.4.2", + "@babel/helper-define-polyfill-provider": "^0.4.3", "semver": "^6.3.1" }, "peerDependencies": { @@ -2421,25 +2440,25 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz", - "integrity": "sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA==", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.6.tgz", + "integrity": "sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.2", - "core-js-compat": "^3.31.0" + "@babel/helper-define-polyfill-provider": "^0.4.3", + "core-js-compat": "^3.33.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.2.tgz", - "integrity": "sha512-tAlOptU0Xj34V1Y2PNTL4Y0FOJMDB6bZmoW39FeCQIhigGLkqu3Fj6uiXpxIf6Ij274ENdYx64y6Au+ZKlb1IA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.3.tgz", + "integrity": "sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.2" + "@babel/helper-define-polyfill-provider": "^0.4.3" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -2504,9 +2523,9 @@ } }, "node_modules/browserslist": { - "version": "4.21.10", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", - "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "dev": true, "funding": [ { @@ -2523,10 +2542,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001517", - "electron-to-chromium": "^1.4.477", + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.11" + "update-browserslist-db": "^1.0.13" }, "bin": { "browserslist": "cli.js" @@ -2551,9 +2570,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001522", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001522.tgz", - "integrity": "sha512-TKiyTVZxJGhsTszLuzb+6vUZSjVOAhClszBr2Ta2k9IwtNBT/4dzmL6aywt0HCgEZlmwJzXJd8yNiob6HgwTRg==", + "version": "1.0.30001563", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001563.tgz", + "integrity": "sha512-na2WUmOxnwIZtwnFI2CZ/3er0wdNzU7hN+cPYz/z2ajHThnkWjNBOpEPP4n+4r2WPM847JaMotaJE3bnfzjyKw==", "dev": true, "funding": [ { @@ -2668,18 +2687,18 @@ "dev": true }, "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, "node_modules/core-js-compat": { - "version": "3.32.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.1.tgz", - "integrity": "sha512-GSvKDv4wE0bPnQtjklV101juQ85g6H3rm5PDP20mqlS5j0kXF3pP97YvAu5hl+uFHqMictp3b2VxOHljWMAtuA==", + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.33.2.tgz", + "integrity": "sha512-axfo+wxFVxnqf8RvxTzoAlzW4gRoacrHeoFlc9n0x50+7BEyZL/Rt3hicaED1/CEd7I6tPCPVUYcJwCMO5XUYw==", "dev": true, "dependencies": { - "browserslist": "^4.21.10" + "browserslist": "^4.22.1" }, "funding": { "type": "opencollective", @@ -2687,14 +2706,14 @@ } }, "node_modules/cosmiconfig": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.2.0.tgz", - "integrity": "sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "dependencies": { - "import-fresh": "^3.2.1", + "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", - "parse-json": "^5.0.0", + "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "engines": { @@ -2702,6 +2721,14 @@ }, "funding": { "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/cross-spawn": { @@ -2829,9 +2856,9 @@ } }, "node_modules/cssdb": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.1.tgz", - "integrity": "sha512-kM+Fs0BFyhJNeE6wbOrlnRsugRdL6vn7QcON0aBDZ7XRd7RI2pMlk+nxoHuTb4Et+aBobXgK0I+6NGLA0LLgTw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.9.0.tgz", + "integrity": "sha512-WPMT9seTQq6fPAa1yN4zjgZZeoTriSN2LqW9C+otjar12DQIWA4LuSfFrvFJiKp4oD0xIk1vumDLw8K9ur4NBw==", "dev": true, "funding": [ { @@ -2891,9 +2918,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.499", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.499.tgz", - "integrity": "sha512-0NmjlYBLKVHva4GABWAaHuPJolnDuL0AhV3h1hES6rcLCWEIbRL6/8TghfsVwkx6TEroQVdliX7+aLysUpKvjw==", + "version": "1.4.588", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.588.tgz", + "integrity": "sha512-soytjxwbgcCu7nh5Pf4S2/4wa6UIu+A3p03U2yVr53qGxi1/VTR3ENI+p50v+UxqqZAfl48j3z55ud7VHIOr9w==", "dev": true }, "node_modules/emojis-list": { @@ -2919,9 +2946,9 @@ } }, "node_modules/envinfo": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.10.0.tgz", - "integrity": "sha512-ZtUjZO6l5mwTHvc1L9+1q5p/R3wTopcfqMW8r5t8SJSKqeVI/LtajORwRFEKpEFuekjD0VBjwu1HMxL4UalIRw==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", "dev": true, "bin": { "envinfo": "dist/cli.js" @@ -2940,9 +2967,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", - "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", "dev": true }, "node_modules/escalade": { @@ -3087,17 +3114,26 @@ "node": ">=8" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, "node_modules/fraction.js": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.1.tgz", - "integrity": "sha512-/KxoyCnPM0GwYI4NN0Iag38Tqt+od3/mLuguepLgCAKPn0ZhC544nssAW0tG2/00zXEYl9W+7hwAIpLHo6Oc7Q==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", "dev": true, "engines": { "node": "*" }, "funding": { "type": "patreon", - "url": "https://www.patreon.com/infusion" + "url": "https://github.com/sponsors/rawify" } }, "node_modules/fsevents": { @@ -3115,10 +3151,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/gensync": { "version": "1.0.0-beta.2", @@ -3162,18 +3201,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -3183,6 +3210,18 @@ "node": ">=4" } }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3208,9 +3247,9 @@ } }, "node_modules/immutable": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.3.tgz", - "integrity": "sha512-808ZFYMsIRAjLAu5xkKo0TsbY9LBy9H5MazTKIEHerNkg0ymgilGfBPMR/3G7d/ihGmuK2Hw8S1izY2d3kd3wA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz", + "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==", "dev": true }, "node_modules/import-fresh": { @@ -3276,12 +3315,12 @@ } }, "node_modules/is-core-module": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", - "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3383,9 +3422,9 @@ } }, "node_modules/jiti": { - "version": "1.19.3", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz", - "integrity": "sha512-5eEbBDQT/jF1xg6l36P+mWGGoH9Spuy0PCdSr2dtWRDGC6ph/w9ZCL4lmESW8f8F7MwT3XKescfP0wnZWAKL9w==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", "dev": true, "bin": { "jiti": "bin/jiti.js" @@ -3572,9 +3611,9 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true, "funding": [ { @@ -3764,9 +3803,9 @@ } }, "node_modules/postcss": { - "version": "8.4.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.28.tgz", - "integrity": "sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -4457,9 +4496,9 @@ "dev": true }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "engines": { "node": ">=6" @@ -4505,9 +4544,9 @@ "dev": true }, "node_modules/regenerate-unicode-properties": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", - "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", + "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", "dev": true, "dependencies": { "regenerate": "^1.4.2" @@ -4570,9 +4609,9 @@ } }, "node_modules/resolve": { - "version": "1.22.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", - "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dev": true, "dependencies": { "is-core-module": "^2.13.0", @@ -4643,9 +4682,9 @@ "dev": true }, "node_modules/sass": { - "version": "1.66.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.66.1.tgz", - "integrity": "sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==", + "version": "1.69.5", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", + "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -4864,9 +4903,9 @@ } }, "node_modules/terser": { - "version": "5.19.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", - "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", + "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -4954,6 +4993,12 @@ "node": ">=8.0" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -4995,9 +5040,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", - "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "dev": true, "funding": [ { @@ -5053,9 +5098,9 @@ } }, "node_modules/webpack": { - "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", @@ -5156,12 +5201,13 @@ } }, "node_modules/webpack-merge": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.9.0.tgz", - "integrity": "sha512-6NbRQw4+Sy50vYNTw7EyOn41OZItPiXB8GNv3INSoe3PSFaHJEz3SHTrYVaRm2LilNGnFUzh0FAwqPEmU/CwDg==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", "dev": true, "dependencies": { "clone-deep": "^4.0.1", + "flat": "^5.0.2", "wildcard": "^2.0.0" }, "engines": { diff --git a/src/main/resources/package.json b/src/main/resources/package.json index 5bf78015..dffb548f 100644 --- a/src/main/resources/package.json +++ b/src/main/resources/package.json @@ -18,18 +18,18 @@ "popper.js": "~1.16.1" }, "devDependencies": { - "@babel/core": "^7.22.10", - "@babel/preset-env": "^7.22.10", + "@babel/core": "^7.23.3", + "@babel/preset-env": "^7.23.3", "babel-loader": "^8.3.0", "css-loader": "^6.8.1", - "postcss": "^8.4.28", + "postcss": "^8.4.31", "postcss-loader": "^7.3.3", "postcss-preset-env": "^7.8.3", - "sass": "^1.66.1", + "sass": "^1.69.5", "sass-loader": "^13.3.2", "source-map-loader": "^4.0.1", "style-loader": "^3.3.3", - "webpack": "^5.88.2", + "webpack": "^5.89.0", "webpack-cli": "^4.10.0" }, "browserslist": [ From 32f14bec907b026fc2fa973f3501b105ae3b3be1 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 17 Nov 2023 13:50:31 -0800 Subject: [PATCH 05/39] Update commons-text to 1.11.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c078a241..8a6ef818 100644 --- a/pom.xml +++ b/pom.xml @@ -152,7 +152,7 @@ org.apache.commons commons-text - 1.10.0 + 1.11.0 From 76c84869150e1c121cb45b82538ff8203ec13c6e Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 17 Nov 2023 14:30:39 -0800 Subject: [PATCH 06/39] Add missing condition when displaying a non-grouping term on the public profile (fix #398) --- src/main/resources/templates/fragments/ontology.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/templates/fragments/ontology.html b/src/main/resources/templates/fragments/ontology.html index 8d78ecf7..ce9bb343 100644 --- a/src/main/resources/templates/fragments/ontology.html +++ b/src/main/resources/templates/fragments/ontology.html @@ -48,7 +48,7 @@
-
Date: Sun, 26 Nov 2023 10:31:33 -0800 Subject: [PATCH 07/39] ci: Update build.yml as per the development setup --- .github/workflows/build.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f24d5e9b..14659823 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,15 +8,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v4.0.0 with: lfs: true - name: Setup Java JDK - uses: actions/setup-java@v1.4.3 + uses: actions/setup-java@v3.12.0 with: - java-version: 1.8 + distribution: temurin + java-version: '8' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.8.1 with: node-version: 16 - name: Test code with Maven From d1d1b4d9126f736b9ae33356e12b16691a652713 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Sun, 26 Nov 2023 10:32:23 -0800 Subject: [PATCH 08/39] Update GitHub actions --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14659823..5f4326f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,16 +8,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.0.0 + - uses: actions/checkout@v4.1.1 with: lfs: true - name: Setup Java JDK - uses: actions/setup-java@v3.12.0 + uses: actions/setup-java@v3.13.0 with: distribution: temurin java-version: '8' - name: Setup Node.js - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v3.8.2 with: node-version: 16 - name: Test code with Maven From 740290f336cb4926bec881c36c75fa84e0fe1999 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 1 Dec 2023 11:34:35 -0800 Subject: [PATCH 09/39] Ensure that only the fields from the form can be filled when registering --- .../rdp/controllers/LoginController.java | 7 +++++++ .../rdp/controllers/LoginControllerTest.java | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java index 9a9ffb03..a211160b 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java @@ -9,7 +9,9 @@ import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.ModelAndView; @@ -42,6 +44,11 @@ public class LoginController { @Autowired private ApplicationSettings applicationSettings; + @InitBinder("user") + public void configureUserDataBinder( WebDataBinder dataBinder ) { + dataBinder.setAllowedFields( "email", "password", "profile.name", "profile.lastName" ); + } + @GetMapping("/login") public ModelAndView login() { ModelAndView modelAndView = new ModelAndView( "login" ); diff --git a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java index 73e919d1..b3093c2a 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -30,6 +31,7 @@ import java.util.Locale; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -108,6 +110,24 @@ public void register_thenReturnSuccess() throws Exception { .andExpect( view().name( "registration" ) ) .andExpect( model().attribute( "user", new User() ) ); when( userService.create( any() ) ).thenAnswer( answer -> answer.getArgument( 0, User.class ) ); + mvc.perform( post( "/registration" ) + .param( "profile.name", "Bob" ) + .param( "profile.lastName", "Smith" ) + .param( "email", "bob@example.com" ) + .param( "password", "123456" ) + .param( "passwordConfirm", "123456" ) + .param( "id", "27" ) ) // this field is ignored + .andExpect( status().is3xxRedirection() ) + .andExpect( redirectedUrl( "/login" ) ); + ArgumentCaptor captor = ArgumentCaptor.forClass( User.class ); + verify( userService ).create( captor.capture() ); + verify( userService ).createVerificationTokenForUser( eq( captor.getValue() ), any() ); + assertThat( captor.getValue() ).satisfies( user -> { + assertThat( user.getId() ).isNull(); + assertThat( user.getEmail() ).isEqualTo( "bob@example.com" ); + assertThat( user.isEnabled() ).isFalse(); + assertThat( user.getAnonymousId() ).isNull(); + } ); } @Test From c08ca01eab2d9141e58bb499e701e9980361a870 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 1 Dec 2023 11:39:41 -0800 Subject: [PATCH 10/39] Ensure only name and email can be set when creating a service account --- .../java/ubc/pavlab/rdp/controllers/AdminController.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/AdminController.java b/src/main/java/ubc/pavlab/rdp/controllers/AdminController.java index e311bd4b..71e96a46 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/AdminController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/AdminController.java @@ -98,6 +98,11 @@ public class AdminController { @Qualifier("adminTaskExecutor") private AsyncTaskExecutor taskExecutor; + @InitBinder("user") + public void configureUserInitBinder( WebDataBinder dataBinder ) { + dataBinder.setAllowedFields( "email", "profile.name" ); + } + /** * List all users */ From 28e1b4a699035c95a0a3e873360fcb00d9e24ddf Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 1 Dec 2023 11:43:44 -0800 Subject: [PATCH 11/39] Fix NPE when no fields in profile are set when registering This is unlikely to happens from the frontend because the browser will send empty profile fields, but it's still possible to trigger the error with a crafted request. --- .../java/ubc/pavlab/rdp/controllers/LoginController.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java index a211160b..0e498dd8 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java @@ -78,6 +78,11 @@ public ModelAndView createNewUser( @Validated(User.ValidationUserAccount.class) ModelAndView modelAndView = new ModelAndView( "registration" ); User existingUser = userService.findUserByEmailNoAuth( user.getEmail() ); + // profile can be missing of no profile.* fields have been set + if ( user.getProfile() == null ) { + user.setProfile( new Profile() ); + } + user.setEnabled( false ); // initialize a basic user profile From 43ac5d1b004d1ef5296205aab7cf7567080f3790 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 1 Dec 2023 11:45:52 -0800 Subject: [PATCH 12/39] Fix model visibility in UserController --- src/main/java/ubc/pavlab/rdp/controllers/UserController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/UserController.java b/src/main/java/ubc/pavlab/rdp/controllers/UserController.java index e639c5ca..8a5625da 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/UserController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/UserController.java @@ -330,7 +330,7 @@ public String verifyContactEmail( @RequestParam String token, RedirectAttributes @Data @Builder - static class ProfileWithOrganUberonIdsAndOntologyTerms { + public static class ProfileWithOrganUberonIdsAndOntologyTerms { /** * Profile */ @@ -425,7 +425,7 @@ public Collection getOntologyTerms() { } @Data - static class Model { + public static class Model { private Map geneTierMap; private Map genePrivacyLevelMap; private List goIds; From bb8cda491abf852914cb1fff3313913f365acce8 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 29 Nov 2023 11:19:30 -0800 Subject: [PATCH 13/39] Add reCAPTCHA and email domain validation --- docs/customization.md | 52 ++++++-- .../java/ubc/pavlab/rdp/ValidationConfig.java | 47 ++++++++ .../rdp/controllers/LoginController.java | 64 +++++++++- src/main/java/ubc/pavlab/rdp/model/User.java | 6 +- .../rdp/settings/ApplicationSettings.java | 18 ++- .../ubc/pavlab/rdp/settings/SiteSettings.java | 11 ++ .../rdp/validation/AllowedDomainStrategy.java | 12 ++ .../pavlab/rdp/validation/EmailValidator.java | 83 +++++++++++++ .../ubc/pavlab/rdp/validation/Recaptcha.java | 10 ++ .../rdp/validation/RecaptchaValidator.java | 60 ++++++++++ .../ResourceBasedAllowedDomainStrategy.java | 111 ++++++++++++++++++ .../SetBasedAllowedDomainStrategy.java | 32 +++++ src/main/resources/application.properties | 7 ++ src/main/resources/messages.properties | 21 +++- .../resources/templates/registration.html | 13 ++ .../rdp/controllers/LoginControllerTest.java | 35 +++++- .../security/EmailValidatorFactoryTest.java | 93 +++++++++++++++ .../rdp/validation/EmailValidatorTest.java | 74 ++++++++++++ .../validation/RecaptchaValidatorTest.java | 7 ++ ...dAllowedDomainStrategyIntegrationTest.java | 15 +++ .../resources/allowed-email-domains-test.txt | 2 + 21 files changed, 754 insertions(+), 19 deletions(-) create mode 100644 src/main/java/ubc/pavlab/rdp/ValidationConfig.java create mode 100644 src/main/java/ubc/pavlab/rdp/validation/AllowedDomainStrategy.java create mode 100644 src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java create mode 100644 src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java create mode 100644 src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java create mode 100644 src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java create mode 100644 src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java create mode 100644 src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java create mode 100644 src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java create mode 100644 src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java create mode 100644 src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java create mode 100644 src/test/resources/allowed-email-domains-test.txt diff --git a/docs/customization.md b/docs/customization.md index d8d6767f..ccd4388e 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -1,5 +1,40 @@ # Customize your instance +## Allowed email domains (new in 1.5.8) + +You may restrict the email domains that can be used for creating new accounts by specifying a file containing one line +per domain. Matches are performed in a case-insensitive manner. + +```properties +rdp.settings.allowed-email-domains-file=file:swot.txt +rdp.settings.allowed-email-domains-refresh-delay=3600 +``` + +This feature is disabled by default. + +Note that [internationalized domains](https://en.wikipedia.org/wiki/Internationalized_domain_name) are not allowed and +will be ignored from the file. + +The default refresh delay is set to one hour. To disable it, you can set `rdp.settings.allowed-email-domains-refresh-delay` +to empty. + +There's a few projects out there that curate institutional email addresses which should be generally suitable + +Refer to [JetBrains/swot](https://github.com/JetBrains/swot) for a list of institu + +## reCAPTCHA (new in 1.5.8) + +RDP supports [reCAPTCHA v2](https://www.google.com/recaptcha/about/) to mitigate the registration of spam accounts by +bots. To enable it, add the reCAPTCHA secret to your configuration. + +```properties +rdp.settings.recaptcha-secret=mysecret +``` + +This feature is disabled by default. + +## Cached data + Most of the data used by the application is retrieved remotely at startup and subsequently updated on a monthly basis. To prevent data from being loaded on startup and/or recurrently, set the following parameter in @@ -12,6 +47,8 @@ rdp.settings.cache.enabled=false You should deploy your RDP instance at least once to have initial data before setting this property and whenever you update the software. +The following sections will cover in details individual data sources that can be imported in your registry. + ## Gene information and GO terms By default, RDP will retrieve the latest gene information from NCBI, and GO terms @@ -271,19 +308,20 @@ The page lists some basic stats at the very top and provides few action buttons: ![Actions available for simple categories.](images/simple-category-actions.png) -- "Deactivate" (or "Deactivate All Terms" in the case of an ontology category): this will remove the category from the Profile and Search pages. This action is reversible, as the category can be easily re-activated. This action is recommended in cases where a category cannot be deleted because it has already been used by some users. +- "Deactivate" (or "Deactivate All Terms" in the case of an ontology category): this will remove the category from the + Profile and Search pages. This action is reversible, as the category can be easily re-activated. This action is + recommended in cases where a category cannot be deleted because it has already been used by some users. - Update from "source": Update the ontology category using the original URL (if available) - Download as OBO: Download the category as an OBO file - - The number of used terms indicate how many terms in the ontology have been associated with associated with users. In the Edit window on the Manage Profile Category page, you can add a definition/description of the category, which is used in a tooltip on the Profile Page. You can also specify if this category will be used as a filter on the Gene -Search page. While all active categories will be available on the Researcher Search page, only categories that have "Available for gene search?" checked will be displayed on the Gene Search page. +Search page. While all active categories will be available on the Researcher Search page, only categories that have " +Available for gene search?" checked will be displayed on the Gene Search page. ![Interface for editing the properties of an ontology.](images/edit-an-ontology.png) @@ -348,8 +386,6 @@ values. A warning will be displayed in the admin section if this is the case. Read more about configuring messages in [Customizing the application messages](#customizing-the-applications-messages) section of this page. - - ### Resolving external URLs By default, ontologies and terms are resolved from [OLS](https://www.ebi.ac.uk/ols/index). Reactome pathways get a @@ -402,7 +438,6 @@ settings will retrieve all the necessary files relative to the working directory #this setting relates only to gene info files. Files for all taxons will be stord under gene/ rdp.settings.cache.load-from-disk=true rdp.settings.cache.gene-files-location=file:genes/ - #file for GO ontology rdp.settings.cache.term-file=file:go.obo #file for gene GO annotation @@ -537,7 +572,8 @@ rdp.faq.questions.=A relevant question. rdp.faq.answers.=A plausible answer. ``` -The provided default file can be found in [faq.properties](https://github.com/PavlidisLab/rdp/tree/{{ config.extra.git_ref }}/src/main/resources/faq.properties). +The provided default file can be found in [faq.properties](https://github.com/PavlidisLab/rdp/tree/{{ +config.extra.git_ref }}/src/main/resources/faq.properties). ### Ordering FAQ entries diff --git a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java new file mode 100644 index 00000000..4a91b7a8 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java @@ -0,0 +1,47 @@ +package ubc.pavlab.rdp; + +import lombok.extern.apachecommons.CommonsLog; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.web.client.RestTemplate; +import ubc.pavlab.rdp.validation.AllowedDomainStrategy; +import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.RecaptchaValidator; +import ubc.pavlab.rdp.validation.ResourceBasedAllowedDomainStrategy; + +import java.io.IOException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +/** + * This configuration provides a few {@link org.springframework.validation.Validator} beans. + */ +@CommonsLog +@Configuration +public class ValidationConfig { + + @Bean + public EmailValidator emailValidator( + @Value("${rdp.settings.allowed-email-domains-file}") Resource allowedEmailDomainsFile, + @Value("${rdp.settings.allowed-email-domains-refresh-delay}") @DurationUnit(ChronoUnit.SECONDS) Duration refreshDelay, + @Value("${rdp.settings.allow-internationalized-domain-names}") boolean allowIdn ) throws IOException { + AllowedDomainStrategy strategy; + if ( allowedEmailDomainsFile == null ) { + strategy = ( domain ) -> true; + log.info( "No allowed email domains file specified, all domains will be allowed for newly registered users." ); + } else { + log.info( "Reading allowed email domains from " + allowedEmailDomainsFile + "..." ); + strategy = new ResourceBasedAllowedDomainStrategy( allowedEmailDomainsFile, refreshDelay ); + ( (ResourceBasedAllowedDomainStrategy) strategy ).refresh(); + } + return new EmailValidator( strategy, allowIdn ); + } + + @Bean + public RecaptchaValidator recaptchaValidator( @Value("${rdp.settings.recaptcha.secret}") String secret ) { + return new RecaptchaValidator( new RestTemplate(), secret ); + } +} diff --git a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java index 0e498dd8..23eb5f57 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java @@ -2,12 +2,13 @@ import lombok.extern.apachecommons.CommonsLog; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; -import org.springframework.validation.BindingResult; +import org.springframework.validation.*; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.GetMapping; @@ -24,6 +25,8 @@ import ubc.pavlab.rdp.services.PrivacyService; import ubc.pavlab.rdp.services.UserService; import ubc.pavlab.rdp.settings.ApplicationSettings; +import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.RecaptchaValidator; import javax.servlet.http.HttpServletRequest; import java.util.Locale; @@ -44,9 +47,49 @@ public class LoginController { @Autowired private ApplicationSettings applicationSettings; + @Autowired + private EmailValidator emailValidator; + + @Autowired + private RecaptchaValidator recaptchaValidator; + + @Autowired + private MessageSource messageSource; + + /** + * Wraps a {@link EmailValidator} so that it can be applied to the {@code user.email} nested path. + */ + private class UserEmailValidator implements Validator { + + @Override + public boolean supports( Class clazz ) { + return User.class.isAssignableFrom( clazz ); + } + + @Override + public void validate( Object target, Errors errors ) { + User user = (User) target; + if ( user.getEmail() != null ) { + try { + errors.pushNestedPath( "email" ); + ValidationUtils.invokeValidator( emailValidator, user.getEmail(), errors ); + } finally { + errors.popNestedPath(); + } + } + } + } + @InitBinder("user") public void configureUserDataBinder( WebDataBinder dataBinder ) { dataBinder.setAllowedFields( "email", "password", "profile.name", "profile.lastName" ); + dataBinder.addValidators( new UserEmailValidator() ); + } + + @InitBinder("recaptcha") + public void configureRecaptchaDataBinder( WebDataBinder dataBinder ) { + dataBinder.setAllowedFields( "secret" ); + dataBinder.addValidators( recaptchaValidator ); } @GetMapping("/login") @@ -87,6 +130,9 @@ public ModelAndView createNewUser( @Validated(User.ValidationUserAccount.class) // initialize a basic user profile Profile userProfile = user.getProfile(); + if ( userProfile == null ) { + userProfile = new Profile(); + } userProfile.setPrivacyLevel( privacyService.getDefaultPrivacyLevel() ); userProfile.setShared( applicationSettings.getPrivacy().isDefaultSharing() ); userProfile.setHideGenelist( false ); @@ -105,6 +151,22 @@ public ModelAndView createNewUser( @Validated(User.ValidationUserAccount.class) if ( bindingResult.hasErrors() ) { modelAndView.setStatus( HttpStatus.BAD_REQUEST ); + // indicate to the mode + boolean isDomainNotAllowed = bindingResult.getFieldErrors( "email" ).stream() + .map( FieldError::getCode ) + .anyMatch( "EmailValidator.domainNotAllowed"::equals ); + modelAndView.addObject( "domainNotAllowed", isDomainNotAllowed ); + if ( isDomainNotAllowed ) { + // this code is not set if the email is not minimally valid, so we can safely parse it + String domain = user.getEmail().split( "@", 2 )[1]; + modelAndView.addObject( "domainNotAllowedFrom", user.getEmail() ); + modelAndView.addObject( "domainNotAllowedSubject", + messageSource.getMessage( "LoginController.domainNotAllowedSubject", + new String[]{ domain }, locale ) ); + modelAndView.addObject( "domainNotAllowedBody", + messageSource.getMessage( "LoginController.domainNotAllowedBody", + new String[]{ user.getEmail(), domain, user.getProfile().getFullName() }, locale ) ); + } } else { user = userService.create( user ); userService.createVerificationTokenForUser( user, locale ); diff --git a/src/main/java/ubc/pavlab/rdp/model/User.java b/src/main/java/ubc/pavlab/rdp/model/User.java index eb1fa50b..ea6ec23c 100644 --- a/src/main/java/ubc/pavlab/rdp/model/User.java +++ b/src/main/java/ubc/pavlab/rdp/model/User.java @@ -4,17 +4,13 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonUnwrapped; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.SchemaProperty; import lombok.*; import lombok.extern.apachecommons.CommonsLog; import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hibernate.annotations.NaturalId; -import org.hibernate.validator.constraints.Email; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import org.springframework.util.StringUtils; import ubc.pavlab.rdp.model.enums.PrivacyLevelType; import ubc.pavlab.rdp.model.enums.TierType; import ubc.pavlab.rdp.model.ontology.Ontology; @@ -89,7 +85,6 @@ public static Comparator getComparator() { @NaturalId @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) @Column(name = "email", unique = true, nullable = false) - @Email(message = "Your email address is not valid.", groups = { ValidationUserAccount.class }) @NotNull(message = "Please provide an email address.", groups = { ValidationUserAccount.class, ValidationServiceAccount.class }) @Size(min = 1, message = "Please provide an email address.", groups = { ValidationUserAccount.class, ValidationServiceAccount.class }) private String email; @@ -144,6 +139,7 @@ public static Comparator getComparator() { private final Set passwordResetTokens = new HashSet<>(); @Valid + @NotNull @Embedded @JsonUnwrapped private Profile profile; diff --git a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java index 630379b0..37808606 100644 --- a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java +++ b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java @@ -6,18 +6,17 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.validation.annotation.Validated; -import ubc.pavlab.rdp.ontology.resolvers.OntologyResolver; import ubc.pavlab.rdp.model.GeneInfo; import ubc.pavlab.rdp.model.enums.PrivacyLevelType; import ubc.pavlab.rdp.model.enums.ResearcherCategory; import ubc.pavlab.rdp.model.enums.ResearcherPosition; import ubc.pavlab.rdp.model.enums.TierType; import ubc.pavlab.rdp.model.ontology.Ontology; +import ubc.pavlab.rdp.ontology.resolvers.OntologyResolver; import ubc.pavlab.rdp.services.GeneInfoService; import javax.validation.constraints.Max; import javax.validation.constraints.Min; -import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Size; import java.net.URI; import java.time.Duration; @@ -273,4 +272,19 @@ public static class OntologySettings { * Enabled tier types. */ public EnumSet enabledTiers; + /** + * File containing allowed email domains for registering users. + *

+ * May be null, in which case any email address will be allowed. + */ + private Resource allowedEmailDomainsFile; + /** + * Refresh delay to reload the allowed email domains file, in seconds. + */ + @DurationUnit(value = ChronoUnit.SECONDS) + private Duration allowedEmailDomainsRefreshDelay; + /** + * Allow internationalized domain names. + */ + private boolean allowInternationalizedDomainNames; } diff --git a/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java b/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java index f451b832..403fafcc 100644 --- a/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java +++ b/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java @@ -47,5 +47,16 @@ public URI getHostUrl() { @NotEmpty(message = "The admin email must be specified.") private String adminEmail; + /** + * GA4 tracker. + */ private String gaTracker; + /** + * Public reCAPTCHA key. + */ + private String recaptchaToken; + /** + * Secret reCAPTCHA key. + */ + private String recaptchaSecret; } diff --git a/src/main/java/ubc/pavlab/rdp/validation/AllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/AllowedDomainStrategy.java new file mode 100644 index 00000000..492794cd --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/AllowedDomainStrategy.java @@ -0,0 +1,12 @@ +package ubc.pavlab.rdp.validation; + +/** + * Defines a strategy to determine if a domain is allowed. + * + * @author poirigui + */ +@FunctionalInterface +public interface AllowedDomainStrategy { + + boolean allows( String domain ); +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java new file mode 100644 index 00000000..475ad385 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java @@ -0,0 +1,83 @@ +package ubc.pavlab.rdp.validation; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import java.net.IDN; +import java.util.Set; + +/** + * Validate an email address against a list of allowed domains. + *

+ * If no list of allowed domains is provided, any domain is allowed and only basic validation is performed. + *

+ * If enabled, this validator can accept international domain names (IDN) and verify them against the list of allowed + * domains by first converting them to Punycode using {@link IDN#toASCII(String)}. + * + * @author poirigui + */ +public class EmailValidator implements Validator { + + /** + * List of allowed domains. + */ + private final AllowedDomainStrategy allowedDomainStrategy; + + /** + * Allow international domain names. + */ + private final boolean allowIdn; + + public EmailValidator() { + this.allowedDomainStrategy = null; + this.allowIdn = false; + } + + public EmailValidator( AllowedDomainStrategy allowedDomainStrategy, boolean allowIdn ) { + this.allowedDomainStrategy = allowedDomainStrategy; + this.allowIdn = allowIdn; + } + + public EmailValidator( Set allowedDomains, boolean allowIdn ) { + this( new SetBasedAllowedDomainStrategy( allowedDomains ), allowIdn ); + } + + @Override + public boolean supports( Class clazz ) { + return String.class.isAssignableFrom( clazz ); + } + + @Override + public void validate( Object target, Errors errors ) { + String email = (String) target; + String[] parts = email.split( "@", 2 ); + if ( parts.length != 2 ) { + errors.rejectValue( null, "EmailValidator.invalidAddress" ); + return; + } + String address = parts[0]; + if ( address.isEmpty() ) { + errors.rejectValue( null, "EmailValidator.emptyUser" ); + } + String domain = parts[1]; + if ( domain.isEmpty() ) { + errors.rejectValue( null, "EmailValidator.emptyDomain" ); + return; + } + if ( allowIdn ) { + try { + domain = IDN.toASCII( domain ); + } catch ( IllegalArgumentException e ) { + errors.rejectValue( null, "EmailValidator.domainNotConformToRfc3490", new String[]{ e.getMessage() }, "" ); + return; + } + } else if ( !StringUtils.isAsciiPrintable( domain ) ) { + errors.rejectValue( null, "EmailValidator.domainContainsUnsupportedCharacters" ); + return; + } + if ( allowedDomainStrategy != null && !allowedDomainStrategy.allows( domain ) ) { + errors.rejectValue( null, "EmailValidator.domainNotAllowed" ); + } + } +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java new file mode 100644 index 00000000..ad7e5a4d --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java @@ -0,0 +1,10 @@ +package ubc.pavlab.rdp.validation; + +import lombok.Data; +import lombok.Value; + +@Value +public class Recaptcha { + String response; + String remoteIp; +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java new file mode 100644 index 00000000..0152c753 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java @@ -0,0 +1,60 @@ +package ubc.pavlab.rdp.validation; + +import lombok.Data; +import lombok.Value; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.web.client.RestTemplate; + +/** + * reCAPTCHA v2 implementation as a Spring validator. + * + * @author poirigui + */ +public class RecaptchaValidator implements Validator { + + private final RestTemplate restTemplate; + private final String secret; + + public RecaptchaValidator( RestTemplate restTemplate, String secret ) { + this.restTemplate = restTemplate; + this.secret = secret; + } + + @Override + public void validate( Object target, Errors errors ) { + Recaptcha recaptcha = (Recaptcha) target; + Reply reply = restTemplate.postForObject( "https://www.google.com/recaptcha/api/siteverify", + new Payload( secret, recaptcha.getResponse(), recaptcha.getRemoteIp() ), Reply.class ); + if ( reply == null ) { + errors.reject( "" ); + return; + } + if ( !reply.success ) { + errors.reject( "" ); + } + for ( String errorCode : reply.errorCodes ) { + errors.reject( errorCode ); + } + } + + @Override + public boolean supports( Class clazz ) { + return Recaptcha.class.isAssignableFrom( clazz ); + } + + @Value + private static class Payload { + String secret; + String response; + String remoteIp; + } + + @Data + private static class Reply { + private boolean success; + private String challengeTs; + private String hostname; + private String[] errorCodes; + } +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java new file mode 100644 index 00000000..fecace8a --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -0,0 +1,111 @@ +package ubc.pavlab.rdp.validation; + +import lombok.extern.apachecommons.CommonsLog; +import org.apache.commons.lang3.time.StopWatch; +import org.springframework.core.io.Resource; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.time.Duration; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A resource-based strategy for allowing domains. + *

+ * The resource is read line-by-line, each line being a domain that will be allowed. The domains validated using a + * {@link SetBasedAllowedDomainStrategy}, so all of its rules regarding ASCII characters and case-insensitivity applies. + *

+ * The strategy accepts an optional refresh delay which it will use to determine if the resource should be reloaded. If + * the resource is backed by a {@link java.io.File}, the last modified date will also be used to prevent unnecessary + * reload. + *

+ * If the refresh fails for any reason, an error is logged and the previous list of allowed domains is used until + * another refresh is attempted. If no previous list of allowed domains exist, the exception will be raised. For this + * reason, you might want to invoke {@link #refresh()} right after creating the strategy to catch any error early otn. + * + * @author poirigui + */ +@CommonsLog +public class ResourceBasedAllowedDomainStrategy implements AllowedDomainStrategy { + + /** + * A resource where email domains are found. + */ + private final Resource allowedEmailDomainsFile; + + /** + * A refresh delay, in ms. + */ + + private final Duration refreshDelay; + + /* internal state */ + private volatile SetBasedAllowedDomainStrategy strategy; + private long lastRefresh; + + public ResourceBasedAllowedDomainStrategy( Resource allowedEmailDomainsFile, Duration refreshDelay ) { + this.allowedEmailDomainsFile = allowedEmailDomainsFile; + this.refreshDelay = refreshDelay; + } + + @Override + public boolean allows( String domain ) { + if ( strategy == null || shouldRefresh() ) { + try { + refresh(); + } catch ( Exception e ) { + if ( strategy == null ) { + throw new RuntimeException( e ); + } else { + // pretend the resource has been refreshed, otherwise it will be reattempted on every request + this.lastRefresh = System.currentTimeMillis(); + log.error( String.format( "An error occurred while refreshing the list of allowed domains from %s. The previous list will be used until the next refresh.", allowedEmailDomainsFile ), e ); + } + } + } + return strategy.allows( domain ); + } + + /** + * Refresh the list of allowed domains. + * + * @throws IOException if an error occurred while reading the resource. + */ + public synchronized void refresh() throws IOException { + StopWatch timer = StopWatch.createStarted(); + Set allowedDomains; + try ( BufferedReader ir = new BufferedReader( new InputStreamReader( allowedEmailDomainsFile.getInputStream() ) ) ) { + allowedDomains = ir.lines().collect( Collectors.toSet() ); + } + strategy = new SetBasedAllowedDomainStrategy( allowedDomains ); + lastRefresh = System.currentTimeMillis(); + log.info( String.format( "Loaded %d domains from %s in %d ms.", allowedDomains.size(), allowedEmailDomainsFile, timer.getTime() ) ); + } + + /** + * Verify if the resource should be reloaded. + */ + private boolean shouldRefresh() { + if ( refreshDelay == null ) { + return false; + } + + // check if the file is stale + if ( System.currentTimeMillis() - lastRefresh >= refreshDelay.toMillis() ) { + try { + // avoid refreshing if the file hasn't changed + return allowedEmailDomainsFile.getFile().lastModified() > lastRefresh; + } catch ( FileNotFoundException ignored ) { + // resource is not backed by a file, most likely + } catch ( IOException e ) { + log.error( String.format( "An error occurred while checking the last modified date of %s.", allowedEmailDomainsFile ), e ); + } + return true; + } + + return false; + } +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java new file mode 100644 index 00000000..47a3e1ea --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java @@ -0,0 +1,32 @@ +package ubc.pavlab.rdp.validation; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; + +/** + * Simple strategy for allowing domain based on a case-insensitive set. + *

+ * The supplied set can only contain domain with ASCII-printable characters. If you want to allow IDN, store + * Punycode in the set and enable IDN in {@link EmailValidator#EmailValidator(Set, boolean)}. + */ +public class SetBasedAllowedDomainStrategy implements AllowedDomainStrategy { + + private final Set allowedDomains; + + public SetBasedAllowedDomainStrategy( Collection allowedDomains ) { + // ascii-only domains, case-insensitive + if ( allowedDomains.stream().anyMatch( d -> !StringUtils.isAsciiPrintable( d ) ) ) { + throw new IllegalArgumentException( "Allowed domains must only contain ASCII-printable characters." ); + } + this.allowedDomains = new TreeSet<>( String.CASE_INSENSITIVE_ORDER ); + this.allowedDomains.addAll( allowedDomains ); + } + + @Override + public boolean allows( String domain ) { + return allowedDomains.contains( domain ); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4a99c2bf..dc607e59 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -73,6 +73,13 @@ server.compression.enabled=true # = Application Specific Defaults # ============================================================== +# File containing a list of allowed email domains (ignored if empty) +rdp.settings.allowed-email-domains-file= +# Refresh delay in seconds (defaults to every hour) +rdp.settings.allowed-email-domains-refresh-delay=3600 +# Allow internationalized domain names +rdp.settings.allow-internationalized-domain-names=false + # Cached gene, orthologs, annotations, etc. rdp.settings.cache.enabled=true rdp.settings.cache.load-from-disk=false diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 53bd53b8..8c5abc66 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -45,6 +45,18 @@ AbstractUserDetailsAuthenticationProvider.expired=User account has expired. AbstractUserDetailsAuthenticationProvider.locked=User account is locked. AbstractUserDetailsAuthenticationProvider.credentialsExpired=User credentials have expired. +# when a domain is not allowed, those are used to prefill the contact email +# {0} contains the domain part +LoginController.domainNotAllowedSubject=Attempting to register with {0} as an email domain is not allowed +# {0} contains the email address, {1} contains the domain part and {2} contains the user's full name +LoginController.domainNotAllowedBody=Hello!\ + \ + I am trying to register {0} and it appears that {1} is not in your allowed list of email domains. Could you please \ + include it? \ + \ + Best,\ + {2} + AbstractSearchController.UserSearchParams.emptyQueryNotAllowed=At least one search criteria must be provided. # {0} contains the taxon id @@ -224,4 +236,11 @@ rdp.ontologies.reactome.definition=Reactome is an open-source, open access, manu # Edit this if you use a different source for orthologs rdp.cache.ortholog-source-description=The ortholog mapping is based on DIOPT version 9 \ -results, filtered for score >5, either best forward or reverse match and Rank = "high" or Rank = "moderate". \ No newline at end of file +results, filtered for score >5, either best forward or reverse match and Rank = "high" or Rank = "moderate". + +EmailValidator.invalidAddress=The email address lacks a '@' character. +EmailValidator.emptyAddress=The user cannot be empty. +EmailValidator.emptyDomain=The domain cannot be empty. +EmailValidator.domainNotConformToRfc3490=The domain is not conform to RFC3490: {0}. +EmailValidator.domainContainsUnsupportedCharacters=The domain contains characters that are not ASCII printable. +EmailValidator.domainNotAllowed=The domain is not included in the allowed set of domains. \ No newline at end of file diff --git a/src/main/resources/templates/registration.html b/src/main/resources/templates/registration.html index 27c51927..9e614a88 100644 --- a/src/main/resources/templates/registration.html +++ b/src/main/resources/templates/registration.html @@ -13,6 +13,14 @@

+
+ Yikes! It looks like your email domain is not in our list of allowed domains. If you think this is a + mistake, + + contact us + + so that we can complete your registration. +
@@ -50,6 +58,8 @@
+ + + \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java index b3093c2a..efb4f637 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java @@ -18,6 +18,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.validation.Errors; import ubc.pavlab.rdp.exception.TokenDoesNotMatchEmailException; import ubc.pavlab.rdp.exception.TokenNotFoundException; import ubc.pavlab.rdp.model.Profile; @@ -28,6 +29,8 @@ import ubc.pavlab.rdp.services.UserService; import ubc.pavlab.rdp.settings.ApplicationSettings; import ubc.pavlab.rdp.settings.SiteSettings; +import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.RecaptchaValidator; import java.util.Locale; @@ -35,8 +38,7 @@ import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -73,9 +75,16 @@ public class LoginControllerTest { @MockBean(name = "ontologyMessageSource") private MessageSource ontologyMessageSource; + @MockBean + private RecaptchaValidator recaptchaValidator; + + @MockBean + private EmailValidator emailValidator; + @BeforeEach public void setUp() { when( privacyService.getDefaultPrivacyLevel() ).thenReturn( PrivacyLevelType.PRIVATE ); + when( emailValidator.supports( String.class ) ).thenReturn( true ); } @Test @@ -130,6 +139,28 @@ public void register_thenReturnSuccess() throws Exception { } ); } + @Test + public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() throws Exception { + doAnswer( a -> { + a.getArgument( 1, Errors.class ).rejectValue( null, "EmailValidator.domainNotAllowed" ); + return null; + } ).when( emailValidator ).validate( eq( "bob@example.com" ), any() ); + when( emailValidator.supports( String.class ) ).thenReturn( true ); + String expectedMailto = "from=foo@example.com&subject=&body="; + mvc.perform( post( "/registration" ) + .param( "profile.name", "Bob" ) + .param( "profile.lastName", "Smith" ) + .param( "email", "bob@example.com" ) + .param( "password", "123456" ) + .param( "passwordConfirm", "123456" ) ) + .andExpect( status().isBadRequest() ) + .andExpect( model().attribute( "domainNotAllowed", true ) ) + .andExpect( model().attribute( "domainNotAllowedFrom", "bob@example.com" ) ) + .andExpect( model().attribute( "domainNotAllowedSubject", "Attempting to register with example.com as an email domain is not allowed" ) ) + .andExpect( model().attribute( "domainNotAllowedBody", "" ) ) + .andExpect( xpath( "a[href ~= 'mailto:'].href" ).string( expectedMailto ) ); + } + @Test @Disabled("I have absolutely no idea why this converter does not work anymore. See https://github.com/PavlidisLab/rdp/issues/171 for details.") public void register_whenEmailIsUsedButNotEnabled_thenResendConfirmation() throws Exception { diff --git a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java new file mode 100644 index 00000000..eae8e109 --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java @@ -0,0 +1,93 @@ +package ubc.pavlab.rdp.security; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.io.PathResource; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.validation.Errors; +import ubc.pavlab.rdp.ValidationConfig; +import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.ResourceBasedAllowedDomainStrategy; + +import java.io.BufferedWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.mockito.Mockito.*; + +@RunWith(SpringRunner.class) +@TestPropertySource(properties = { + "rdp.settings.allowed-email-domains-file=classpath:allowed-email-domains-test.txt", + "rdp.settings.allowed-email-domains-refresh-delay=PT1S", + "rdp.settings.allow-internationalized-domain-names=true" +}) +public class EmailValidatorFactoryTest { + + @TestConfiguration + @Import(ValidationConfig.class) + static class EmailValidatorFactoryTestContextConfiguration { + + @Bean + public ConversionService conversionService() { + return new DefaultFormattingConversionService(); + } + } + + @Autowired + private EmailValidator emailValidator; + + @Test + public void test() throws Exception { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + } + + @Test + public void testUnrecognizedDomain() throws Exception { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc2.ca", errors ); + verify( errors ).reject( "EmailValidator.domainNotAllowed" ); + } + + @Test + public void testReloadAfterDelay() throws Exception { + Path tmpFile = Files.createTempFile( "test", null ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc.ca" ); + } + + EmailValidator v = new EmailValidator( new ResourceBasedAllowedDomainStrategy( new PathResource( tmpFile ), Duration.ofMillis( 100 ) ), false ); + + Errors errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + // clearing the file + } + + // no immediate change + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + // until the refresh delay expires... + Thread.sleep( 100 ); + + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verify( errors ).reject( "EmailValidator.domainNotAllowed" ); + assertNotSame( v, emailValidator ); + } +} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java new file mode 100644 index 00000000..3c22b974 --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java @@ -0,0 +1,74 @@ +package ubc.pavlab.rdp.validation; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.springframework.validation.Errors; + +import java.util.Collections; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class EmailValidatorTest { + + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private Errors e; + + private EmailValidator v; + + @Before + public void setUp() { + v = new EmailValidator(); + } + + @Test + public void validate_whenDomainIsAllowed_thenAccept() { + v.validate( "test@test.com", e ); + verifyNoInteractions( e ); + } + + @Test + public void validate_whenDomainIsNotInAllowedDomains_thenReject() { + v = new EmailValidator( Collections.singleton( "test.com" ), false ); + v.validate( "test@test2.com", e ); + verify( e ).rejectValue( null, "EmailValidator.domainNotAllowed" ); + } + + @Test + public void validate_whenIdnIsEnabledAndDomainHasUnicodeSymbols_thenAccept() { + v = new EmailValidator( (AllowedDomainStrategy) null, true ); + v.validate( "foo@Bücher.example", e ); + verifyNoInteractions( e ); + } + + @Test + public void validate_whenDomainContainsUnsupportedCharacters_thenReject() { + v.validate( "foo@Bücher.example", e ); + verify( e ).rejectValue( null, "EmailValidator.domainContainsUnsupportedCharacters" ); + } + + @Test + public void validate_whenDomainIsMissing_thenReject() { + v.validate( "test", e ); + verify( e ).rejectValue( null, "EmailValidator.invalidAddress" ); + } + + @Test + public void validate_whenDomainIsEmpty_thenReject() { + v.validate( "test@", e ); + verify( e ).rejectValue( null, "EmailValidator.emptyDomain" ); + } + + @Test + public void validate_whenAddressIsEmpty_thenReject() { + v.validate( "@test.com", e ); + verify( e ).rejectValue( null, "EmailValidator.emptyUser" ); + } +} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java new file mode 100644 index 00000000..b7b4e144 --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java @@ -0,0 +1,7 @@ +package ubc.pavlab.rdp.validation; + +import static org.junit.Assert.*; + +public class RecaptchaValidatorTest { + +} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java b/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java new file mode 100644 index 00000000..38e9c714 --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java @@ -0,0 +1,15 @@ +package ubc.pavlab.rdp.validation; + +import org.junit.Test; +import org.springframework.core.io.UrlResource; + +import java.io.IOException; + +public class ResourceBasedAllowedDomainStrategyIntegrationTest { + + @Test + public void testWithJetBrainsSwot() throws IOException { + ResourceBasedAllowedDomainStrategy strategy = new ResourceBasedAllowedDomainStrategy( new UrlResource( "https://github.com/JetBrains/swot/releases/download/latest/swot.txt" ), null ); + strategy.refresh(); + } +} \ No newline at end of file diff --git a/src/test/resources/allowed-email-domains-test.txt b/src/test/resources/allowed-email-domains-test.txt new file mode 100644 index 00000000..f5f79fb3 --- /dev/null +++ b/src/test/resources/allowed-email-domains-test.txt @@ -0,0 +1,2 @@ +example.com +ubc.ca \ No newline at end of file From 738fecf63a536f20de1b5acd518226f9374c1341 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 11 Dec 2023 15:50:42 -0500 Subject: [PATCH 14/39] Update docker-compose.yml file --- docker-compose.yml | 74 ++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f79b52e8..773302f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,36 +1,38 @@ -mysql56: - image: mysql:5.6 - ports: - - "3306:3306" - environment: - - MYSQL_USER=springuser - - MYSQL_PASSWORD=ThePassword - - MYSQL_DATABASE=db_example - - MYSQL_RANDOM_ROOT_PASSWORD=true -mysql57: - image: mysql:5.7 - ports: - - "3306:3306" - environment: - - MYSQL_USER=springuser - - MYSQL_PASSWORD=ThePassword - - MYSQL_DATABASE=db_example - - MYSQL_RANDOM_ROOT_PASSWORD=true -mysql: - image: mysql:8.0 - ports: - - "3306:3306" - environment: - - MYSQL_USER=springuser - - MYSQL_PASSWORD=ThePassword - - MYSQL_DATABASE=db_example - - MYSQL_RANDOM_ROOT_PASSWORD=true -mariadb: - image: mariadb:10.6 - ports: - - "3306:3306" - environment: - - MARIADB_USER=springuser - - MARIADB_PASSWORD=ThePassword - - MARIADB_DATABASE=db_example - - MARIADB_RANDOM_ROOT_PASSWORD=true +version: "3.8" +services: + mysql56: + image: mysql:5.6 + ports: + - "3306:3306" + environment: + - MYSQL_USER=springuser + - MYSQL_PASSWORD=ThePassword + - MYSQL_DATABASE=db_example + - MYSQL_RANDOM_ROOT_PASSWORD=true + mysql57: + image: mysql:5.7 + ports: + - "3306:3306" + environment: + - MYSQL_USER=springuser + - MYSQL_PASSWORD=ThePassword + - MYSQL_DATABASE=db_example + - MYSQL_RANDOM_ROOT_PASSWORD=true + mysql: + image: mysql:8.0 + ports: + - "3306:3306" + environment: + - MYSQL_USER=springuser + - MYSQL_PASSWORD=ThePassword + - MYSQL_DATABASE=db_example + - MYSQL_RANDOM_ROOT_PASSWORD=true + mariadb: + image: mariadb:10.6 + ports: + - "3306:3306" + environment: + - MARIADB_USER=springuser + - MARIADB_PASSWORD=ThePassword + - MARIADB_DATABASE=db_example + - MARIADB_RANDOM_ROOT_PASSWORD=true From 86eb7da2d31db4659547485aa5ae25564e79de01 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 11 Dec 2023 23:12:29 -0500 Subject: [PATCH 15/39] Finish reCAPTCHA and email validation --- docs/customization.md | 3 +- .../java/ubc/pavlab/rdp/ValidationConfig.java | 24 +++-- .../rdp/controllers/LoginController.java | 31 ++++--- .../pavlab/rdp/validation/EmailValidator.java | 6 +- .../ubc/pavlab/rdp/validation/Recaptcha.java | 5 +- .../rdp/validation/RecaptchaValidator.java | 44 ++++++--- .../ResourceBasedAllowedDomainStrategy.java | 26 +++++- .../SetBasedAllowedDomainStrategy.java | 5 + src/main/resources/application.properties | 4 + src/main/resources/messages.properties | 26 ++++-- .../resources/templates/registration.html | 20 ++-- .../rdp/controllers/LoginControllerTest.java | 16 +++- .../security/EmailValidatorFactoryTest.java | 93 ------------------- .../EmailValidatorWithContextTest.java | 53 +++++++++++ .../rdp/validation/EmailValidatorTest.java | 64 +++++++++---- .../validation/RecaptchaValidatorTest.java | 85 ++++++++++++++++- 16 files changed, 341 insertions(+), 164 deletions(-) delete mode 100644 src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java create mode 100644 src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java diff --git a/docs/customization.md b/docs/customization.md index ccd4388e..0dcabf0c 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -28,7 +28,8 @@ RDP supports [reCAPTCHA v2](https://www.google.com/recaptcha/about/) to mitigate bots. To enable it, add the reCAPTCHA secret to your configuration. ```properties -rdp.settings.recaptcha-secret=mysecret +rdp.site.recaptcha-token=mytoken +rdp.site.recaptcha-secret=mysecret ``` This feature is disabled by default. diff --git a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java index 4a91b7a8..f5e46ea6 100644 --- a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java +++ b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java @@ -2,10 +2,12 @@ import lombok.extern.apachecommons.CommonsLog; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.convert.DurationUnit; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; +import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.web.client.RestTemplate; import ubc.pavlab.rdp.validation.AllowedDomainStrategy; import ubc.pavlab.rdp.validation.EmailValidator; @@ -15,6 +17,7 @@ import java.io.IOException; import java.time.Duration; import java.time.temporal.ChronoUnit; +import java.util.Set; /** * This configuration provides a few {@link org.springframework.validation.Validator} beans. @@ -29,19 +32,28 @@ public EmailValidator emailValidator( @Value("${rdp.settings.allowed-email-domains-refresh-delay}") @DurationUnit(ChronoUnit.SECONDS) Duration refreshDelay, @Value("${rdp.settings.allow-internationalized-domain-names}") boolean allowIdn ) throws IOException { AllowedDomainStrategy strategy; - if ( allowedEmailDomainsFile == null ) { - strategy = ( domain ) -> true; - log.info( "No allowed email domains file specified, all domains will be allowed for newly registered users." ); - } else { + if ( allowedEmailDomainsFile != null ) { log.info( "Reading allowed email domains from " + allowedEmailDomainsFile + "..." ); strategy = new ResourceBasedAllowedDomainStrategy( allowedEmailDomainsFile, refreshDelay ); ( (ResourceBasedAllowedDomainStrategy) strategy ).refresh(); + Set allowedDomains = ( (ResourceBasedAllowedDomainStrategy) strategy ).getAllowedDomains(); + if ( allowedDomains.size() <= 5 ) { + log.info( String.format( "Email validation is configured to accept only addresses from: %s.", String.join( ", ", allowedDomains ) ) ); + } else { + log.info( String.format( "Email validation is configured to accept only addresses from a list of %d domains.", allowedDomains.size() ) ); + } + } else { + strategy = ( domain ) -> true; + log.warn( "No allowed email domains file specified, all domains will be allowed for newly registered users." ); } return new EmailValidator( strategy, allowIdn ); } @Bean - public RecaptchaValidator recaptchaValidator( @Value("${rdp.settings.recaptcha.secret}") String secret ) { - return new RecaptchaValidator( new RestTemplate(), secret ); + @ConditionalOnProperty("rdp.site.recaptcha-secret") + public RecaptchaValidator recaptchaValidator( @Value("${rdp.site.recaptcha-secret}") String secret ) { + RestTemplate rt = new RestTemplate(); + rt.getMessageConverters().add( new FormHttpMessageConverter() ); + return new RecaptchaValidator( rt, secret ); } } diff --git a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java index 23eb5f57..7cb7cc22 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java @@ -11,10 +11,7 @@ import org.springframework.validation.*; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.WebDataBinder; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.InitBinder; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import ubc.pavlab.rdp.exception.TokenException; @@ -26,10 +23,13 @@ import ubc.pavlab.rdp.services.UserService; import ubc.pavlab.rdp.settings.ApplicationSettings; import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.Recaptcha; import ubc.pavlab.rdp.validation.RecaptchaValidator; import javax.servlet.http.HttpServletRequest; +import java.util.List; import java.util.Locale; +import java.util.stream.Collectors; /** * Created by mjacobson on 16/01/18. @@ -50,7 +50,7 @@ public class LoginController { @Autowired private EmailValidator emailValidator; - @Autowired + @Autowired(required = false) private RecaptchaValidator recaptchaValidator; @Autowired @@ -86,12 +86,6 @@ public void configureUserDataBinder( WebDataBinder dataBinder ) { dataBinder.addValidators( new UserEmailValidator() ); } - @InitBinder("recaptcha") - public void configureRecaptchaDataBinder( WebDataBinder dataBinder ) { - dataBinder.setAllowedFields( "secret" ); - dataBinder.addValidators( recaptchaValidator ); - } - @GetMapping("/login") public ModelAndView login() { ModelAndView modelAndView = new ModelAndView( "login" ); @@ -116,9 +110,24 @@ public ModelAndView registration() { @PostMapping("/registration") public ModelAndView createNewUser( @Validated(User.ValidationUserAccount.class) User user, BindingResult bindingResult, + @RequestParam(name = "g-recaptcha-response", required = false) String recaptchaResponse, + @RequestHeader(name = "X-Forwarded-For", required = false) List clientIp, RedirectAttributes redirectAttributes, Locale locale ) { ModelAndView modelAndView = new ModelAndView( "registration" ); + + if ( recaptchaValidator != null ) { + Recaptcha recaptcha = new Recaptcha( recaptchaResponse, clientIp != null ? clientIp.iterator().next() : null ); + BindingResult recaptchaBindingResult = new BeanPropertyBindingResult( recaptcha, "recaptcha" ); + recaptchaValidator.validate( recaptcha, recaptchaBindingResult ); + if ( recaptchaBindingResult.hasErrors() ) { + modelAndView.setStatus( HttpStatus.BAD_REQUEST ); + modelAndView.addObject( "message", recaptchaBindingResult.getAllErrors().stream().map( oe -> messageSource.getMessage( oe, locale ) ).collect( Collectors.joining( "
" ) ) ); + modelAndView.addObject( "error", Boolean.TRUE ); + return modelAndView; + } + } + User existingUser = userService.findUserByEmailNoAuth( user.getEmail() ); // profile can be missing of no profile.* fields have been set diff --git a/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java index 475ad385..da5db924 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java +++ b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java @@ -69,7 +69,7 @@ public void validate( Object target, Errors errors ) { try { domain = IDN.toASCII( domain ); } catch ( IllegalArgumentException e ) { - errors.rejectValue( null, "EmailValidator.domainNotConformToRfc3490", new String[]{ e.getMessage() }, "" ); + errors.rejectValue( null, "EmailValidator.domainNotConformToRfc3490", new String[]{ e.getMessage() }, null ); return; } } else if ( !StringUtils.isAsciiPrintable( domain ) ) { @@ -77,7 +77,9 @@ public void validate( Object target, Errors errors ) { return; } if ( allowedDomainStrategy != null && !allowedDomainStrategy.allows( domain ) ) { - errors.rejectValue( null, "EmailValidator.domainNotAllowed" ); + // at this point, the domain only contains ascii-printable, so it can safely be passed back to the user in + // an error message + errors.rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ domain }, null ); } } } diff --git a/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java index ad7e5a4d..32695818 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java +++ b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java @@ -1,10 +1,13 @@ package ubc.pavlab.rdp.validation; -import lombok.Data; import lombok.Value; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestParam; @Value public class Recaptcha { String response; + @Nullable String remoteIp; } diff --git a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java index 0152c753..6e0feb37 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java +++ b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java @@ -1,7 +1,16 @@ package ubc.pavlab.rdp.validation; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import lombok.Data; -import lombok.Value; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.validation.Errors; import org.springframework.validation.Validator; import org.springframework.web.client.RestTemplate; @@ -17,6 +26,9 @@ public class RecaptchaValidator implements Validator { private final String secret; public RecaptchaValidator( RestTemplate restTemplate, String secret ) { + Assert.isTrue( restTemplate.getMessageConverters().stream().anyMatch( converter -> converter.canWrite( MultiValueMap.class, MediaType.APPLICATION_FORM_URLENCODED ) ), + "The supplied RestTemplate must support writing " + MediaType.APPLICATION_FORM_URLENCODED_VALUE + " messages." ); + Assert.isTrue( StringUtils.isNotBlank( secret ), "The secret must not be empty." ); this.restTemplate = restTemplate; this.secret = secret; } @@ -24,17 +36,28 @@ public RecaptchaValidator( RestTemplate restTemplate, String secret ) { @Override public void validate( Object target, Errors errors ) { Recaptcha recaptcha = (Recaptcha) target; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType( MediaType.APPLICATION_FORM_URLENCODED ); + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add( "secret", secret ); + payload.add( "response", recaptcha.getResponse() ); + if ( recaptcha.getRemoteIp() != null ) { + payload.add( "remoteip", recaptcha.getRemoteIp() ); + } + HttpEntity> requestEntity = new HttpEntity<>( payload, headers ); Reply reply = restTemplate.postForObject( "https://www.google.com/recaptcha/api/siteverify", - new Payload( secret, recaptcha.getResponse(), recaptcha.getRemoteIp() ), Reply.class ); + requestEntity, Reply.class ); if ( reply == null ) { - errors.reject( "" ); + errors.reject( "RecaptchaValidator.empty-reply" ); return; } if ( !reply.success ) { - errors.reject( "" ); + errors.reject( "RecaptchaValidator.unsuccessful-response" ); } - for ( String errorCode : reply.errorCodes ) { - errors.reject( errorCode ); + if ( reply.errorCodes != null ) { + for ( String errorCode : reply.errorCodes ) { + errors.reject( "RecaptchaValidator." + errorCode ); + } } } @@ -43,18 +66,13 @@ public boolean supports( Class clazz ) { return Recaptcha.class.isAssignableFrom( clazz ); } - @Value - private static class Payload { - String secret; - String response; - String remoteIp; - } - @Data + @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) private static class Reply { private boolean success; private String challengeTs; private String hostname; + @Nullable private String[] errorCodes; } } diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java index fecace8a..bf56ac67 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -9,6 +9,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.time.Duration; +import java.util.Collections; import java.util.Set; import java.util.stream.Collectors; @@ -31,6 +32,11 @@ @CommonsLog public class ResourceBasedAllowedDomainStrategy implements AllowedDomainStrategy { + /** + * Resolution to use when comparing the last modified of a file against some recorded timestamp. + */ + private final static int LAST_MODIFIED_RESOLUTION_MS = 2; + /** * A resource where email domains are found. */ @@ -85,6 +91,17 @@ public synchronized void refresh() throws IOException { log.info( String.format( "Loaded %d domains from %s in %d ms.", allowedDomains.size(), allowedEmailDomainsFile, timer.getTime() ) ); } + /** + * Obtain a set of allowed email domains. + */ + public Set getAllowedDomains() { + if ( strategy == null ) { + return Collections.emptySet(); + } else { + return strategy.getAllowedDomains(); + } + } + /** * Verify if the resource should be reloaded. */ @@ -94,10 +111,15 @@ private boolean shouldRefresh() { } // check if the file is stale + if ( System.currentTimeMillis() - lastRefresh >= refreshDelay.toMillis() ) { try { - // avoid refreshing if the file hasn't changed - return allowedEmailDomainsFile.getFile().lastModified() > lastRefresh; + long lastModified = allowedEmailDomainsFile.getFile().lastModified(); + if ( lastModified == 0L ) { + // error reading the last modified, assume it's stale + return true; + } + return lastModified + LAST_MODIFIED_RESOLUTION_MS > lastRefresh; } catch ( FileNotFoundException ignored ) { // resource is not backed by a file, most likely } catch ( IOException e ) { diff --git a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java index 47a3e1ea..21b73271 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java @@ -3,6 +3,7 @@ import org.apache.commons.lang3.StringUtils; import java.util.Collection; +import java.util.Collections; import java.util.Set; import java.util.TreeSet; @@ -29,4 +30,8 @@ public SetBasedAllowedDomainStrategy( Collection allowedDomains ) { public boolean allows( String domain ) { return allowedDomains.contains( domain ); } + + public Set getAllowedDomains() { + return Collections.unmodifiableSet( allowedDomains ); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dc607e59..5c8c63bb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -125,6 +125,10 @@ rdp.site.theme-color=#285187 ### Google Analytics ### rdp.site.ga-tracker= +### reCAPTCHA v2 ### +#rdp.site.recaptcha-token= +#rdp.site.recaptcha-secret= + # ============================================================== # = FAQ # ============================================================== diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 8c5abc66..778c2b43 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -49,14 +49,26 @@ AbstractUserDetailsAuthenticationProvider.credentialsExpired=User credentials ha # {0} contains the domain part LoginController.domainNotAllowedSubject=Attempting to register with {0} as an email domain is not allowed # {0} contains the email address, {1} contains the domain part and {2} contains the user's full name -LoginController.domainNotAllowedBody=Hello!\ - \ +LoginController.domainNotAllowedBody=Hello!\n\ + \n\ I am trying to register {0} and it appears that {1} is not in your allowed list of email domains. Could you please \ - include it? \ - \ - Best,\ + include it?\n\ + \n\ + Best,\n\ {2} +RecaptchaValidator.emtpy-reply=The reply from the reCAPTCHA service was empty. +RecaptchaValidator.unsuccessful-response=The reCAPTCHA was not successful. + +# those codes are defined in https://developers.google.com/recaptcha/docs/verify +RecaptchaValidator.missing-input-secret=The secret parameter is missing. +RecaptchaValidator.invalid-input-secret=The secret parameter is invalid or malformed. +RecaptchaValidator.missing-input-response=The response parameter is missing. +RecaptchaValidator.invalid-input-response=The response parameter is invalid or malformed. +RecaptchaValidator.bad-request=The request is invalid or malformed. +RecaptchaValidator.timeout-or-duplicate=The response is no longer valid: either is too old or has been used previously. + + AbstractSearchController.UserSearchParams.emptyQueryNotAllowed=At least one search criteria must be provided. # {0} contains the taxon id @@ -239,8 +251,8 @@ rdp.cache.ortholog-source-description=The ortholog mapping is based on
-
- Yikes! It looks like your email domain is not in our list of allowed domains. If you think this is a - mistake, - + - +
@@ -58,8 +57,8 @@
- + + \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java index efb4f637..29d4ec2f 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java @@ -1,5 +1,6 @@ package ubc.pavlab.rdp.controllers; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -30,6 +31,7 @@ import ubc.pavlab.rdp.settings.ApplicationSettings; import ubc.pavlab.rdp.settings.SiteSettings; import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.Recaptcha; import ubc.pavlab.rdp.validation.RecaptchaValidator; import java.util.Locale; @@ -120,11 +122,13 @@ public void register_thenReturnSuccess() throws Exception { .andExpect( model().attribute( "user", new User() ) ); when( userService.create( any() ) ).thenAnswer( answer -> answer.getArgument( 0, User.class ) ); mvc.perform( post( "/registration" ) + .header( "X-Forwarded-For", "127.0.0.1", "10.0.0.2" ) .param( "profile.name", "Bob" ) .param( "profile.lastName", "Smith" ) .param( "email", "bob@example.com" ) .param( "password", "123456" ) .param( "passwordConfirm", "123456" ) + .param( "g-recaptcha-response", "1234" ) .param( "id", "27" ) ) // this field is ignored .andExpect( status().is3xxRedirection() ) .andExpect( redirectedUrl( "/login" ) ); @@ -137,6 +141,12 @@ public void register_thenReturnSuccess() throws Exception { assertThat( user.isEnabled() ).isFalse(); assertThat( user.getAnonymousId() ).isNull(); } ); + ArgumentCaptor recaptchaCaptor = ArgumentCaptor.forClass( Recaptcha.class ); + verify( recaptchaValidator ).validate( recaptchaCaptor.capture(), any() ); + assertThat( recaptchaCaptor.getValue() ).satisfies( r -> { + assertThat( r.getResponse() ).isEqualTo( "1234" ); + assertThat( r.getRemoteIp() ).isEqualTo( "127.0.0.1" ); + } ); } @Test @@ -146,7 +156,7 @@ public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() th return null; } ).when( emailValidator ).validate( eq( "bob@example.com" ), any() ); when( emailValidator.supports( String.class ) ).thenReturn( true ); - String expectedMailto = "from=foo@example.com&subject=&body="; + String expectedMailto = "mailto:admin@...from=foo@example.com&subject=&body="; mvc.perform( post( "/registration" ) .param( "profile.name", "Bob" ) .param( "profile.lastName", "Smith" ) @@ -157,8 +167,8 @@ public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() th .andExpect( model().attribute( "domainNotAllowed", true ) ) .andExpect( model().attribute( "domainNotAllowedFrom", "bob@example.com" ) ) .andExpect( model().attribute( "domainNotAllowedSubject", "Attempting to register with example.com as an email domain is not allowed" ) ) - .andExpect( model().attribute( "domainNotAllowedBody", "" ) ) - .andExpect( xpath( "a[href ~= 'mailto:'].href" ).string( expectedMailto ) ); + .andExpect( model().attribute( "domainNotAllowedBody", containsString( "bob@example.com" ) ) ) + .andExpect( xpath( "//a[starts-with(@href, 'mailto:')]/@href" ).string( Matchers.startsWith( "mailto:support@example.com?from=bob@example.com&subject=Attempting" ) ) ); } @Test diff --git a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java deleted file mode 100644 index eae8e109..00000000 --- a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package ubc.pavlab.rdp.security; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.core.convert.ConversionService; -import org.springframework.core.io.PathResource; -import org.springframework.format.support.DefaultFormattingConversionService; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit4.SpringRunner; -import org.springframework.validation.Errors; -import ubc.pavlab.rdp.ValidationConfig; -import ubc.pavlab.rdp.validation.EmailValidator; -import ubc.pavlab.rdp.validation.ResourceBasedAllowedDomainStrategy; - -import java.io.BufferedWriter; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.mockito.Mockito.*; - -@RunWith(SpringRunner.class) -@TestPropertySource(properties = { - "rdp.settings.allowed-email-domains-file=classpath:allowed-email-domains-test.txt", - "rdp.settings.allowed-email-domains-refresh-delay=PT1S", - "rdp.settings.allow-internationalized-domain-names=true" -}) -public class EmailValidatorFactoryTest { - - @TestConfiguration - @Import(ValidationConfig.class) - static class EmailValidatorFactoryTestContextConfiguration { - - @Bean - public ConversionService conversionService() { - return new DefaultFormattingConversionService(); - } - } - - @Autowired - private EmailValidator emailValidator; - - @Test - public void test() throws Exception { - Errors errors = mock( Errors.class ); - emailValidator.validate( "foo@ubc.ca", errors ); - verifyNoInteractions( errors ); - } - - @Test - public void testUnrecognizedDomain() throws Exception { - Errors errors = mock( Errors.class ); - emailValidator.validate( "foo@ubc2.ca", errors ); - verify( errors ).reject( "EmailValidator.domainNotAllowed" ); - } - - @Test - public void testReloadAfterDelay() throws Exception { - Path tmpFile = Files.createTempFile( "test", null ); - - try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { - writer.write( "ubc.ca" ); - } - - EmailValidator v = new EmailValidator( new ResourceBasedAllowedDomainStrategy( new PathResource( tmpFile ), Duration.ofMillis( 100 ) ), false ); - - Errors errors = mock( Errors.class ); - v.validate( "foo@ubc.ca", errors ); - verifyNoInteractions( errors ); - - try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { - // clearing the file - } - - // no immediate change - errors = mock( Errors.class ); - v.validate( "foo@ubc.ca", errors ); - verifyNoInteractions( errors ); - - // until the refresh delay expires... - Thread.sleep( 100 ); - - errors = mock( Errors.class ); - v.validate( "foo@ubc.ca", errors ); - verify( errors ).reject( "EmailValidator.domainNotAllowed" ); - assertNotSame( v, emailValidator ); - } -} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java new file mode 100644 index 00000000..1a67b71b --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java @@ -0,0 +1,53 @@ +package ubc.pavlab.rdp.security; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.validation.Errors; +import ubc.pavlab.rdp.ValidationConfig; +import ubc.pavlab.rdp.validation.EmailValidator; + +import static org.mockito.Mockito.*; + +@ExtendWith(SpringExtension.class) +@TestPropertySource(properties = { + "rdp.settings.allowed-email-domains-file=classpath:allowed-email-domains-test.txt", + "rdp.settings.allowed-email-domains-refresh-delay=PT0.1S", + "rdp.settings.allow-internationalized-domain-names=true" +}) +public class EmailValidatorWithContextTest { + + @TestConfiguration + @Import(ValidationConfig.class) + static class EmailValidatorFactoryTestContextConfiguration { + + @Bean + public ConversionService conversionService() { + return new DefaultFormattingConversionService(); + } + } + + @Autowired + private EmailValidator emailValidator; + + @Test + public void test() { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + } + + @Test + public void testUnrecognizedDomain() { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc2.ca", errors ); + verify( errors ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "ubc2.ca" }, null ); + } +} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java index 3c22b974..81c6cb5f 100644 --- a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java +++ b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java @@ -1,31 +1,28 @@ package ubc.pavlab.rdp.validation; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.PathResource; import org.springframework.validation.Errors; +import java.io.BufferedWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; import java.util.Collections; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.*; public class EmailValidatorTest { - @Rule - public MockitoRule mockitoRule = MockitoJUnit.rule(); - - @Mock - private Errors e; - private EmailValidator v; + private Errors e; - @Before + @BeforeEach public void setUp() { v = new EmailValidator(); + e = mock( Errors.class ); } @Test @@ -38,7 +35,7 @@ public void validate_whenDomainIsAllowed_thenAccept() { public void validate_whenDomainIsNotInAllowedDomains_thenReject() { v = new EmailValidator( Collections.singleton( "test.com" ), false ); v.validate( "test@test2.com", e ); - verify( e ).rejectValue( null, "EmailValidator.domainNotAllowed" ); + verify( e ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "test2.com" }, null ); } @Test @@ -71,4 +68,39 @@ public void validate_whenAddressIsEmpty_thenReject() { v.validate( "@test.com", e ); verify( e ).rejectValue( null, "EmailValidator.emptyUser" ); } + + @RepeatedTest(10) + public void validate_whenDelayForRefreshingExpiresAndDomainIsRemoved_thenReject() throws Exception { + Path tmpFile = Files.createTempFile( "test", null ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc.ca" ); + } + + EmailValidator v = new EmailValidator( new ResourceBasedAllowedDomainStrategy( new PathResource( tmpFile ), Duration.ofMillis( 50 ) ), false ); + + Errors errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc2.ca" ); + } + + // no immediate change + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + // until the refresh delay expires... + Thread.sleep( 50 ); + + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verify( errors ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "ubc.ca" }, null ); + + errors = mock( Errors.class ); + v.validate( "foo@ubc2.ca", errors ); + verifyNoInteractions( errors ); + } } \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java index b7b4e144..d08cda17 100644 --- a/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java +++ b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java @@ -1,7 +1,90 @@ package ubc.pavlab.rdp.validation; -import static org.junit.Assert.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Value; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.web.client.RestTemplate; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +@JsonTest public class RecaptchaValidatorTest { + private RestTemplate restTemplate = new RestTemplate(); + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void test() throws JsonProcessingException { + MockRestServiceServer mockServer = MockRestServiceServer.createServer( restTemplate ); + MultiValueMap expectedFormData = new LinkedMultiValueMap<>(); + expectedFormData.add( "secret", "1234" ); + expectedFormData.add( "response", "I'm human." ); + expectedFormData.add( "remoteip", "127.0.0.1" ); + mockServer.expect( requestTo( "https://www.google.com/recaptcha/api/siteverify" ) ) + .andExpect( content().formData( expectedFormData ) ) + .andRespond( withStatus( HttpStatus.OK ).contentType( MediaType.APPLICATION_JSON ) + .body( objectMapper.writeValueAsString( new Reply( true, "", "localhost", null ) ) ) ); + Validator validator = new RecaptchaValidator( restTemplate, "1234" ); + Recaptcha recaptcha = new Recaptcha( "I'm human.", "127.0.0.1" ); + Errors errors = new BeanPropertyBindingResult( recaptcha, "recaptcha" ); + validator.validate( recaptcha, errors ); + assertThat( errors.hasErrors() ).withFailMessage( errors.toString() ).isFalse(); + mockServer.verify(); + } + + @Test + public void testInvalidRecaptchaResponse() throws JsonProcessingException { + MockRestServiceServer mockServer = MockRestServiceServer.createServer( restTemplate ); + MultiValueMap expectedFormData = new LinkedMultiValueMap<>(); + expectedFormData.add( "secret", "1234" ); + expectedFormData.add( "response", "I'm a robot." ); + expectedFormData.add( "remoteip", "127.0.0.1" ); + mockServer.expect( requestTo( "https://www.google.com/recaptcha/api/siteverify" ) ) + .andExpect( content().formData( expectedFormData ) ) + .andRespond( withStatus( HttpStatus.OK ).contentType( MediaType.APPLICATION_JSON ) + .body( objectMapper.writeValueAsString( new Reply( false, "", "localhost", new String[]{ + "invalid-input-secret" + } ) ) ) ); + Validator validator = new RecaptchaValidator( restTemplate, "1234" ); + Recaptcha recaptcha = new Recaptcha( "I'm a robot.", "127.0.0.1" ); + Errors errors = new BeanPropertyBindingResult( recaptcha, "recaptcha" ); + validator.validate( recaptcha, errors ); + assertThat( errors.hasErrors() ).isTrue(); + assertThat( errors.getGlobalErrors() ) + .satisfiesExactlyInAnyOrder( ( f ) -> { + assertThat( f.getCode() ).isEqualTo( "RecaptchaValidator.unsuccessful-response" ); + }, ( f ) -> { + assertThat( f.getCode() ).isEqualTo( "RecaptchaValidator.invalid-input-secret" ); + } ); + mockServer.verify(); + } + + @Value + @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) + private static class Reply { + boolean success; + String challengeTs; + String hostname; + @Nullable + String[] errorCodes; + } } \ No newline at end of file From f44fad69ba9d7809a542169dcbb1b63ce97f9016 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 01:30:50 -0500 Subject: [PATCH 16/39] Ignore non-ascii-printable lines from the domain file --- .../rdp/validation/ResourceBasedAllowedDomainStrategy.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java index bf56ac67..26003b8c 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -1,6 +1,7 @@ package ubc.pavlab.rdp.validation; import lombok.extern.apachecommons.CommonsLog; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.time.StopWatch; import org.springframework.core.io.Resource; @@ -84,7 +85,8 @@ public synchronized void refresh() throws IOException { StopWatch timer = StopWatch.createStarted(); Set allowedDomains; try ( BufferedReader ir = new BufferedReader( new InputStreamReader( allowedEmailDomainsFile.getInputStream() ) ) ) { - allowedDomains = ir.lines().collect( Collectors.toSet() ); + // TODO: warn for rejected lines + allowedDomains = ir.lines().filter( StringUtils::isAsciiPrintable ).collect( Collectors.toSet() ); } strategy = new SetBasedAllowedDomainStrategy( allowedDomains ); lastRefresh = System.currentTimeMillis(); From ac73a8879c2cb4203dd16227cfda0236955e481b Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 01:43:42 -0500 Subject: [PATCH 17/39] Add a test for a domain that contains an invalid IDN character --- .../ubc/pavlab/rdp/validation/EmailValidatorTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java index 81c6cb5f..40a061c6 100644 --- a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java +++ b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java @@ -45,6 +45,14 @@ public void validate_whenIdnIsEnabledAndDomainHasUnicodeSymbols_thenAccept() { verifyNoInteractions( e ); } + @Test + public void validate_whenIdnIsEnabledAndDomainHasInvalidUnicodeSymbols_thenReject() { + v = new EmailValidator( (AllowedDomainStrategy) null, true ); + // that's the code for a chequered flag 🏁 + v.validate( "foo@B\uD83C\uDFC1cher.example", e ); + verify( e ).rejectValue( isNull(), eq( "EmailValidator.domainNotConformToRfc3490" ), any(), isNull() ); + } + @Test public void validate_whenDomainContainsUnsupportedCharacters_thenReject() { v.validate( "foo@Bücher.example", e ); From 61681c63ce3232effdf15700b8d8715ba83cedaa Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 10:21:46 -0500 Subject: [PATCH 18/39] Improve assertion for ensuring that all allowed domains are ascii-printable --- .../java/ubc/pavlab/rdp/validation/RecaptchaValidator.java | 1 - .../rdp/validation/SetBasedAllowedDomainStrategy.java | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java index 6e0feb37..b282fda2 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java +++ b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java @@ -21,7 +21,6 @@ * @author poirigui */ public class RecaptchaValidator implements Validator { - private final RestTemplate restTemplate; private final String secret; diff --git a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java index 21b73271..2985bd66 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java @@ -1,6 +1,7 @@ package ubc.pavlab.rdp.validation; import org.apache.commons.lang3.StringUtils; +import org.springframework.util.Assert; import java.util.Collection; import java.util.Collections; @@ -19,9 +20,8 @@ public class SetBasedAllowedDomainStrategy implements AllowedDomainStrategy { public SetBasedAllowedDomainStrategy( Collection allowedDomains ) { // ascii-only domains, case-insensitive - if ( allowedDomains.stream().anyMatch( d -> !StringUtils.isAsciiPrintable( d ) ) ) { - throw new IllegalArgumentException( "Allowed domains must only contain ASCII-printable characters." ); - } + Assert.isTrue( allowedDomains.stream().allMatch( StringUtils::isAsciiPrintable ), + "Allowed domains must only contain ASCII-printable characters." ); this.allowedDomains = new TreeSet<>( String.CASE_INSENSITIVE_ORDER ); this.allowedDomains.addAll( allowedDomains ); } From cbeac33c3f6e17ec6d2645e9f1e8df2d53b2caaa Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 12:01:06 -0500 Subject: [PATCH 19/39] Allow domains to be specified from a list in addition to a file. Use Apache FileUtils.lastModified() to check for the last modified timestamp and handle the IOException properly. Improve naming consistency for settings. --- .../java/ubc/pavlab/rdp/ValidationConfig.java | 44 +++++++++++++------ .../rdp/settings/ApplicationSettings.java | 13 ++++-- .../ResourceBasedAllowedDomainStrategy.java | 15 +++---- src/main/resources/application.properties | 4 +- .../EmailValidatorWithContextTest.java | 12 ++++- .../rdp/validation/EmailValidatorTest.java | 27 ++++++++++++ 6 files changed, 85 insertions(+), 30 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java index f5e46ea6..034cfdd1 100644 --- a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java +++ b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java @@ -9,14 +9,13 @@ import org.springframework.core.io.Resource; import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.web.client.RestTemplate; -import ubc.pavlab.rdp.validation.AllowedDomainStrategy; -import ubc.pavlab.rdp.validation.EmailValidator; -import ubc.pavlab.rdp.validation.RecaptchaValidator; -import ubc.pavlab.rdp.validation.ResourceBasedAllowedDomainStrategy; +import ubc.pavlab.rdp.validation.*; import java.io.IOException; import java.time.Duration; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; import java.util.Set; /** @@ -28,23 +27,40 @@ public class ValidationConfig { @Bean public EmailValidator emailValidator( + @Value("${rdp.settings.allowed-email-domains}") List allowedEmailDomains, @Value("${rdp.settings.allowed-email-domains-file}") Resource allowedEmailDomainsFile, - @Value("${rdp.settings.allowed-email-domains-refresh-delay}") @DurationUnit(ChronoUnit.SECONDS) Duration refreshDelay, - @Value("${rdp.settings.allow-internationalized-domain-names}") boolean allowIdn ) throws IOException { - AllowedDomainStrategy strategy; + @Value("${rdp.settings.allowed-email-domains-file-refresh-delay}") @DurationUnit(ChronoUnit.SECONDS) Duration refreshDelay, + @Value("${rdp.settings.allow-internationalized-email-domains}") boolean allowIdn ) throws IOException { + List strategies = new ArrayList<>(); + if ( allowedEmailDomains != null && !allowedEmailDomains.isEmpty() ) { + SetBasedAllowedDomainStrategy strategy = new SetBasedAllowedDomainStrategy( allowedEmailDomains ); + strategies.add( strategy ); + log.info( String.format( "Email validation is configured to accept addresses from: %s.", String.join( ", ", + strategy.getAllowedDomains() ) ) ); + } if ( allowedEmailDomainsFile != null ) { log.info( "Reading allowed email domains from " + allowedEmailDomainsFile + "..." ); - strategy = new ResourceBasedAllowedDomainStrategy( allowedEmailDomainsFile, refreshDelay ); - ( (ResourceBasedAllowedDomainStrategy) strategy ).refresh(); - Set allowedDomains = ( (ResourceBasedAllowedDomainStrategy) strategy ).getAllowedDomains(); - if ( allowedDomains.size() <= 5 ) { - log.info( String.format( "Email validation is configured to accept only addresses from: %s.", String.join( ", ", allowedDomains ) ) ); + if ( refreshDelay.isZero() ) { + log.warn( "The refresh delay for reading " + allowedEmailDomainsFile + " is set to zero: the file will be re-read for every email domain validation." ); + } + ResourceBasedAllowedDomainStrategy strategy = new ResourceBasedAllowedDomainStrategy( allowedEmailDomainsFile, refreshDelay ); + strategy.refresh(); + Set allowedDomains = strategy.getAllowedDomains(); + strategies.add( strategy ); + if ( strategy.getAllowedDomains().size() <= 5 ) { + log.info( String.format( "Email validation is configured to accept addresses from: %s.", String.join( ", ", allowedDomains ) ) ); } else { - log.info( String.format( "Email validation is configured to accept only addresses from a list of %d domains.", allowedDomains.size() ) ); + log.info( String.format( "Email validation is configured to accept addresses from a list of %d domains.", allowedDomains.size() ) ); } - } else { + } + AllowedDomainStrategy strategy; + if ( strategies.isEmpty() ) { strategy = ( domain ) -> true; log.warn( "No allowed email domains file specified, all domains will be allowed for newly registered users." ); + } else if ( strategies.size() == 1 ) { + strategy = strategies.iterator().next(); + } else { + strategy = domain -> strategies.stream().anyMatch( s -> s.allows( domain ) ); } return new EmailValidator( strategy, allowIdn ); } diff --git a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java index 37808606..4b2293cb 100644 --- a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java +++ b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java @@ -272,6 +272,12 @@ public static class OntologySettings { * Enabled tier types. */ public EnumSet enabledTiers; + /** + * List of allowed email domains for registering users. + *

+ * May be null or empty, in which case any email address will be allowed. + */ + private List allowedEmailDomains; /** * File containing allowed email domains for registering users. *

@@ -282,9 +288,10 @@ public static class OntologySettings { * Refresh delay to reload the allowed email domains file, in seconds. */ @DurationUnit(value = ChronoUnit.SECONDS) - private Duration allowedEmailDomainsRefreshDelay; + private Duration allowedEmailDomainsFileRefreshDelay; /** - * Allow internationalized domain names. + * Allow internationalized domain names. + * If set to true, Punycode can be added to {@link #allowedEmailDomains} or {@link #allowedEmailDomainsFile}. */ - private boolean allowInternationalizedDomainNames; + private boolean allowInternationalizedEmailDomains; } diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java index 26003b8c..75f465e2 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -1,6 +1,7 @@ package ubc.pavlab.rdp.validation; import lombok.extern.apachecommons.CommonsLog; +import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.time.StopWatch; import org.springframework.core.io.Resource; @@ -34,7 +35,8 @@ public class ResourceBasedAllowedDomainStrategy implements AllowedDomainStrategy { /** - * Resolution to use when comparing the last modified of a file against some recorded timestamp. + * Resolution to use when comparing the last modified of a file against a recorded timestamp with + * {@link System#currentTimeMillis()}. */ private final static int LAST_MODIFIED_RESOLUTION_MS = 2; @@ -111,16 +113,10 @@ private boolean shouldRefresh() { if ( refreshDelay == null ) { return false; } - - // check if the file is stale - if ( System.currentTimeMillis() - lastRefresh >= refreshDelay.toMillis() ) { + // check if the file is stale try { - long lastModified = allowedEmailDomainsFile.getFile().lastModified(); - if ( lastModified == 0L ) { - // error reading the last modified, assume it's stale - return true; - } + long lastModified = FileUtils.lastModified( allowedEmailDomainsFile.getFile() ); return lastModified + LAST_MODIFIED_RESOLUTION_MS > lastRefresh; } catch ( FileNotFoundException ignored ) { // resource is not backed by a file, most likely @@ -129,7 +125,6 @@ private boolean shouldRefresh() { } return true; } - return false; } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c8c63bb..76f14b9d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -73,12 +73,14 @@ server.compression.enabled=true # = Application Specific Defaults # ============================================================== +# A comma-delimited list of allowed email domains (ignored if empty) +rdp.settings.allowed-email-domains= # File containing a list of allowed email domains (ignored if empty) rdp.settings.allowed-email-domains-file= # Refresh delay in seconds (defaults to every hour) rdp.settings.allowed-email-domains-refresh-delay=3600 # Allow internationalized domain names -rdp.settings.allow-internationalized-domain-names=false +rdp.settings.allow-internationalized-domains=false # Cached gene, orthologs, annotations, etc. rdp.settings.cache.enabled=true diff --git a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java index 1a67b71b..30fbca4c 100644 --- a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java +++ b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java @@ -18,9 +18,10 @@ @ExtendWith(SpringExtension.class) @TestPropertySource(properties = { + "rdp.settings.allowed-email-domains=ubc3.ca", "rdp.settings.allowed-email-domains-file=classpath:allowed-email-domains-test.txt", - "rdp.settings.allowed-email-domains-refresh-delay=PT0.1S", - "rdp.settings.allow-internationalized-domain-names=true" + "rdp.settings.allowed-email-domains-file-refresh-delay=PT0.1S", + "rdp.settings.allow-internationalized-email-domains=true" }) public class EmailValidatorWithContextTest { @@ -44,6 +45,13 @@ public void test() { verifyNoInteractions( errors ); } + @Test + public void testDomainFromList() { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc3.ca", errors ); + verifyNoInteractions( errors ); + } + @Test public void testUnrecognizedDomain() { Errors errors = mock( Errors.class ); diff --git a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java index 40a061c6..1e98291c 100644 --- a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java +++ b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java @@ -111,4 +111,31 @@ public void validate_whenDelayForRefreshingExpiresAndDomainIsRemoved_thenReject( v.validate( "foo@ubc2.ca", errors ); verifyNoInteractions( errors ); } + + @Test + public void validate_whenDelayForRefreshingIsZero() throws Exception { + Path tmpFile = Files.createTempFile( "test", null ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc.ca" ); + } + + EmailValidator v = new EmailValidator( new ResourceBasedAllowedDomainStrategy( new PathResource( tmpFile ), Duration.ofMillis( 0 ) ), false ); + + Errors errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc2.ca" ); + } + + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verify( errors ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "ubc.ca" }, null ); + + errors = mock( Errors.class ); + v.validate( "foo@ubc2.ca", errors ); + verifyNoInteractions( errors ); + } } \ No newline at end of file From 0e62a4a77a882e6d02fd3ad6258e84a2cb25d091 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 12:03:57 -0500 Subject: [PATCH 20/39] Update documentation --- docs/customization.md | 37 ++++++++++++------- .../rdp/validation/RecaptchaValidator.java | 1 + src/main/resources/application.properties | 4 +- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/customization.md b/docs/customization.md index 0dcabf0c..48910b67 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -1,33 +1,42 @@ # Customize your instance -## Allowed email domains (new in 1.5.8) +This section contains instruction to customize your RDP registry. -You may restrict the email domains that can be used for creating new accounts by specifying a file containing one line -per domain. Matches are performed in a case-insensitive manner. +## Allowed email providers (new in 1.5.8) -```properties +You may restrict which email providers can be used for creating new accounts by specifying a list or a file +containing allowed domains. Matches are performed in a case-insensitive manner. Only printable ASCII characters are +allowed. + +```ini +rdp.settings.allowed-email-domains=example.com rdp.settings.allowed-email-domains-file=file:swot.txt -rdp.settings.allowed-email-domains-refresh-delay=3600 +rdp.settings.allowed-email-domains-file-refresh-delay=3600 ``` +The default refresh delay is set to one hour. Disable it by setting it to an empty value. A value of `0` will cause a +refresh on every validation. + This feature is disabled by default. -Note that [internationalized domains](https://en.wikipedia.org/wiki/Internationalized_domain_name) are not allowed and -will be ignored from the file. +### Internationalized domain names -The default refresh delay is set to one hour. To disable it, you can set `rdp.settings.allowed-email-domains-refresh-delay` -to empty. +To use [internationalized domain names](https://en.wikipedia.org/wiki/Internationalized_domain_name) in email addresses, +add their Punycode to the list or file and set the following setting: -There's a few projects out there that curate institutional email addresses which should be generally suitable +```ini +rdp.settings.allow-internationalized-email-domains=true +``` -Refer to [JetBrains/swot](https://github.com/JetBrains/swot) for a list of institu +For example, to allow users from `universität.example.com` to register, add `xn--universitt-y5a.example.com` to the +file. ## reCAPTCHA (new in 1.5.8) -RDP supports [reCAPTCHA v2](https://www.google.com/recaptcha/about/) to mitigate the registration of spam accounts by -bots. To enable it, add the reCAPTCHA secret to your configuration. +RDP supports [reCAPTCHA v2](https://www.google.com/recaptcha/about/) to mitigate the registration of accounts by bots. +To enable it, add the reCAPTCHA token and secret to your configuration. -```properties +```ini rdp.site.recaptcha-token=mytoken rdp.site.recaptcha-secret=mysecret ``` diff --git a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java index b282fda2..6e0feb37 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java +++ b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java @@ -21,6 +21,7 @@ * @author poirigui */ public class RecaptchaValidator implements Validator { + private final RestTemplate restTemplate; private final String secret; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 76f14b9d..5b10b5e9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -78,9 +78,9 @@ rdp.settings.allowed-email-domains= # File containing a list of allowed email domains (ignored if empty) rdp.settings.allowed-email-domains-file= # Refresh delay in seconds (defaults to every hour) -rdp.settings.allowed-email-domains-refresh-delay=3600 +rdp.settings.allowed-email-domains-file-refresh-delay=3600 # Allow internationalized domain names -rdp.settings.allow-internationalized-domains=false +rdp.settings.allow-internationalized-email-domains=false # Cached gene, orthologs, annotations, etc. rdp.settings.cache.enabled=true From c9412e37c81e70556ac485e1fc623e4fd4378c74 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 14:20:41 -0500 Subject: [PATCH 21/39] Increase resolution to 10ms --- .../rdp/validation/ResourceBasedAllowedDomainStrategy.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java index 75f465e2..13890ad5 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -38,7 +38,7 @@ public class ResourceBasedAllowedDomainStrategy implements AllowedDomainStrategy * Resolution to use when comparing the last modified of a file against a recorded timestamp with * {@link System#currentTimeMillis()}. */ - private final static int LAST_MODIFIED_RESOLUTION_MS = 2; + private final static int LAST_MODIFIED_RESOLUTION_MS = 10; /** * A resource where email domains are found. From 4f36dcdc17371be0eedcd17d609216aed3430933 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 15:44:27 -0500 Subject: [PATCH 22/39] Fix encoding of body in the email template --- src/main/resources/messages.properties | 6 +++--- src/main/resources/templates/registration.html | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 778c2b43..ffaf8a7d 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -47,12 +47,12 @@ AbstractUserDetailsAuthenticationProvider.credentialsExpired=User credentials ha # when a domain is not allowed, those are used to prefill the contact email # {0} contains the domain part -LoginController.domainNotAllowedSubject=Attempting to register with {0} as an email domain is not allowed +LoginController.domainNotAllowedSubject=Register with an email address from {0} # {0} contains the email address, {1} contains the domain part and {2} contains the user's full name LoginController.domainNotAllowedBody=Hello!\n\ \n\ - I am trying to register {0} and it appears that {1} is not in your allowed list of email domains. Could you please \ - include it?\n\ + I am trying to register with {0} and it appears that {1} is not an allowed email provider. Could you please include \ + it?\n\ \n\ Best,\n\ {2} diff --git a/src/main/resources/templates/registration.html b/src/main/resources/templates/registration.html index a62db1a3..831504e8 100644 --- a/src/main/resources/templates/registration.html +++ b/src/main/resources/templates/registration.html @@ -14,8 +14,9 @@

- Yikes! It looks like your email does not belong to an allowed provider. If you think this is a mistake, - + Yikes! It looks like your email address was not issued from an allowed provider. If you think this is a + mistake, + contact us so that we can complete your registration. From 324b74eca4a873aec95bc2abb4a7f7de30cfae28 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 15:52:29 -0500 Subject: [PATCH 23/39] Add warnings when invalid lines are encoutered in the allowed domains file --- .../ResourceBasedAllowedDomainStrategy.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java index 13890ad5..2d8eac86 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -12,8 +12,8 @@ import java.io.InputStreamReader; import java.time.Duration; import java.util.Collections; +import java.util.HashSet; import java.util.Set; -import java.util.stream.Collectors; /** * A resource-based strategy for allowing domains. @@ -87,8 +87,17 @@ public synchronized void refresh() throws IOException { StopWatch timer = StopWatch.createStarted(); Set allowedDomains; try ( BufferedReader ir = new BufferedReader( new InputStreamReader( allowedEmailDomainsFile.getInputStream() ) ) ) { - // TODO: warn for rejected lines - allowedDomains = ir.lines().filter( StringUtils::isAsciiPrintable ).collect( Collectors.toSet() ); + allowedDomains = new HashSet<>(); + String line; + int lineno = 0; + while ( ( line = ir.readLine() ) != null ) { + lineno++; + if ( StringUtils.isAsciiPrintable( line ) ) { + allowedDomains.add( line.trim() ); + } else { + log.warn( String.format( "Invalid characters in line %d from %s, it will be ignored.", lineno, allowedEmailDomainsFile ) ); + } + } } strategy = new SetBasedAllowedDomainStrategy( allowedDomains ); lastRefresh = System.currentTimeMillis(); From ebdc01e62151b01e3063d277a26f64f005908a9b Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 15:53:27 -0500 Subject: [PATCH 24/39] Include warning logs when running tests --- src/test/resources/logback-test.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index 66a08244..a8aab834 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + \ No newline at end of file From a358af7bf4245f936e2cc41931330fc78f10fecb Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 15:54:58 -0500 Subject: [PATCH 25/39] Slight improvement for the email message --- src/main/resources/messages.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index ffaf8a7d..9e1b0df9 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -51,8 +51,8 @@ LoginController.domainNotAllowedSubject=Register with an email address from {0} # {0} contains the email address, {1} contains the domain part and {2} contains the user's full name LoginController.domainNotAllowedBody=Hello!\n\ \n\ - I am trying to register with {0} and it appears that {1} is not an allowed email provider. Could you please include \ - it?\n\ + I am trying to register an account with {0} and it appears that {1} is not an allowed email provider. Could you please\ + include it?\n\ \n\ Best,\n\ {2} From 40cb5c3210cb4cac5955c90db99768e140bda062 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 11 Dec 2023 15:50:42 -0500 Subject: [PATCH 26/39] Update docker-compose.yml file --- docker-compose.yml | 74 ++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f79b52e8..773302f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,36 +1,38 @@ -mysql56: - image: mysql:5.6 - ports: - - "3306:3306" - environment: - - MYSQL_USER=springuser - - MYSQL_PASSWORD=ThePassword - - MYSQL_DATABASE=db_example - - MYSQL_RANDOM_ROOT_PASSWORD=true -mysql57: - image: mysql:5.7 - ports: - - "3306:3306" - environment: - - MYSQL_USER=springuser - - MYSQL_PASSWORD=ThePassword - - MYSQL_DATABASE=db_example - - MYSQL_RANDOM_ROOT_PASSWORD=true -mysql: - image: mysql:8.0 - ports: - - "3306:3306" - environment: - - MYSQL_USER=springuser - - MYSQL_PASSWORD=ThePassword - - MYSQL_DATABASE=db_example - - MYSQL_RANDOM_ROOT_PASSWORD=true -mariadb: - image: mariadb:10.6 - ports: - - "3306:3306" - environment: - - MARIADB_USER=springuser - - MARIADB_PASSWORD=ThePassword - - MARIADB_DATABASE=db_example - - MARIADB_RANDOM_ROOT_PASSWORD=true +version: "3.8" +services: + mysql56: + image: mysql:5.6 + ports: + - "3306:3306" + environment: + - MYSQL_USER=springuser + - MYSQL_PASSWORD=ThePassword + - MYSQL_DATABASE=db_example + - MYSQL_RANDOM_ROOT_PASSWORD=true + mysql57: + image: mysql:5.7 + ports: + - "3306:3306" + environment: + - MYSQL_USER=springuser + - MYSQL_PASSWORD=ThePassword + - MYSQL_DATABASE=db_example + - MYSQL_RANDOM_ROOT_PASSWORD=true + mysql: + image: mysql:8.0 + ports: + - "3306:3306" + environment: + - MYSQL_USER=springuser + - MYSQL_PASSWORD=ThePassword + - MYSQL_DATABASE=db_example + - MYSQL_RANDOM_ROOT_PASSWORD=true + mariadb: + image: mariadb:10.6 + ports: + - "3306:3306" + environment: + - MARIADB_USER=springuser + - MARIADB_PASSWORD=ThePassword + - MARIADB_DATABASE=db_example + - MARIADB_RANDOM_ROOT_PASSWORD=true From 751efd19f1c1cb77a4c621c2172229673572011d Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 16:47:10 -0500 Subject: [PATCH 27/39] Fix LoginControllerTest --- .../java/ubc/pavlab/rdp/controllers/LoginControllerTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java index 29d4ec2f..9ae02ff4 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java @@ -156,7 +156,6 @@ public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() th return null; } ).when( emailValidator ).validate( eq( "bob@example.com" ), any() ); when( emailValidator.supports( String.class ) ).thenReturn( true ); - String expectedMailto = "mailto:admin@...from=foo@example.com&subject=&body="; mvc.perform( post( "/registration" ) .param( "profile.name", "Bob" ) .param( "profile.lastName", "Smith" ) @@ -166,9 +165,9 @@ public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() th .andExpect( status().isBadRequest() ) .andExpect( model().attribute( "domainNotAllowed", true ) ) .andExpect( model().attribute( "domainNotAllowedFrom", "bob@example.com" ) ) - .andExpect( model().attribute( "domainNotAllowedSubject", "Attempting to register with example.com as an email domain is not allowed" ) ) + .andExpect( model().attribute( "domainNotAllowedSubject", "Register with an email address from example.com" ) ) .andExpect( model().attribute( "domainNotAllowedBody", containsString( "bob@example.com" ) ) ) - .andExpect( xpath( "//a[starts-with(@href, 'mailto:')]/@href" ).string( Matchers.startsWith( "mailto:support@example.com?from=bob@example.com&subject=Attempting" ) ) ); + .andExpect( xpath( "//a[starts-with(@href, 'mailto:')]/@href" ).string( Matchers.startsWith( "mailto:support@example.com?from=bob@example.com&subject=Register" ) ) ); } @Test From f2873bab020b224cc77f3e0edf717104193ef9ff Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Tue, 12 Dec 2023 16:49:42 -0500 Subject: [PATCH 28/39] Revert "Include warning logs when running tests" This reverts commit ebdc01e62151b01e3063d277a26f64f005908a9b. --- src/test/resources/logback-test.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index a8aab834..66a08244 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + \ No newline at end of file From 74c876461eb0a2a1b142eec3d07e8cc12e4ede11 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 13 Dec 2023 10:36:01 -0500 Subject: [PATCH 29/39] Clarify behaviour of rdp.settings.allowed-email-domains-file-refresh-delay in the settings --- src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5b10b5e9..c87467b7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -77,7 +77,7 @@ server.compression.enabled=true rdp.settings.allowed-email-domains= # File containing a list of allowed email domains (ignored if empty) rdp.settings.allowed-email-domains-file= -# Refresh delay in seconds (defaults to every hour) +# Refresh delay in seconds (ignored if empty, always refresh if set to zero) rdp.settings.allowed-email-domains-file-refresh-delay=3600 # Allow internationalized domain names rdp.settings.allow-internationalized-email-domains=false From 4c7661a30d488c324eed6ee18fda9875c21417b2 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 15:10:30 -0500 Subject: [PATCH 30/39] Fix concatenation in registration message body --- src/main/resources/messages.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 9e1b0df9..fea1492e 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -51,8 +51,8 @@ LoginController.domainNotAllowedSubject=Register with an email address from {0} # {0} contains the email address, {1} contains the domain part and {2} contains the user's full name LoginController.domainNotAllowedBody=Hello!\n\ \n\ - I am trying to register an account with {0} and it appears that {1} is not an allowed email provider. Could you please\ - include it?\n\ + I am trying to register an account with {0} and it appears that {1} is not an allowed email provider. Could you \ + please include it?\n\ \n\ Best,\n\ {2} From 5b5dc49c8bb47c1e78f248b8289a9608622f33c6 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 16:39:51 -0500 Subject: [PATCH 31/39] Minor adjustment for the warning message for invalid domains --- src/main/resources/templates/registration.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/templates/registration.html b/src/main/resources/templates/registration.html index 831504e8..7c355e69 100644 --- a/src/main/resources/templates/registration.html +++ b/src/main/resources/templates/registration.html @@ -14,7 +14,7 @@
- Yikes! It looks like your email address was not issued from an allowed provider. If you think this is a + It looks like your email address does not match one of the approved email domains. If you think this is a mistake, contact us From 483739b263f44a44b8c9841e75a272db1b20bfaa Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 16:43:52 -0500 Subject: [PATCH 32/39] Add required .readthedocs.yaml --- .readthedocs.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..fdf99623 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.12" +mkdocs: + configuration: mkdocs.yml +python: + install: + - requirements: docs/requirements.txt From 51ddfbb3ba41e24dfeee6c88055b0bcb0193234d Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 16:43:52 -0500 Subject: [PATCH 33/39] Add required .readthedocs.yaml --- .readthedocs.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..fdf99623 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.12" +mkdocs: + configuration: mkdocs.yml +python: + install: + - requirements: docs/requirements.txt From ec54d987dc47ba80df456f9f273d3d5cd20faa10 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 16:48:44 -0500 Subject: [PATCH 34/39] Update yauaa to 7.24.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8a6ef818..a76cc387 100644 --- a/pom.xml +++ b/pom.xml @@ -159,7 +159,7 @@ nl.basjes.parse.useragent yauaa - 7.23.0 + 7.24.0 From 8f5adc6da76dacc95f27767b6c4453f99d501679 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 17:28:48 -0500 Subject: [PATCH 35/39] Add an assumption that swot.txt exists to run the test --- .../ResourceBasedAllowedDomainStrategyIntegrationTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java b/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java index 38e9c714..42ba171a 100644 --- a/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java +++ b/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java @@ -5,11 +5,15 @@ import java.io.IOException; +import static org.assertj.core.api.Assumptions.assumeThat; + public class ResourceBasedAllowedDomainStrategyIntegrationTest { @Test public void testWithJetBrainsSwot() throws IOException { - ResourceBasedAllowedDomainStrategy strategy = new ResourceBasedAllowedDomainStrategy( new UrlResource( "https://github.com/JetBrains/swot/releases/download/latest/swot.txt" ), null ); + UrlResource resource = new UrlResource( "https://github.com/JetBrains/swot/releases/download/latest/swot.txt" ); + assumeThat( resource.exists() ).isTrue(); + ResourceBasedAllowedDomainStrategy strategy = new ResourceBasedAllowedDomainStrategy( resource, null ); strategy.refresh(); } } \ No newline at end of file From 3c4ab28fb3ad0393a41a28385c0661392ed22501 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 18:25:00 -0500 Subject: [PATCH 36/39] Fix read-only transaction exception for creating and revoking access tokens (fix #382) --- src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index f3d5a24f..34e3884d 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -381,11 +381,13 @@ public UserGene anonymizeUserGene( UserGene userGene, UUID anonymousIdToReuse ) } @Override + @Transactional public void revokeAccessToken( AccessToken accessToken ) { accessTokenRepository.delete( accessToken ); } @Override + @Transactional public AccessToken createAccessTokenForUser( User user ) { AccessToken token = new AccessToken(); token.updateToken( createSecureRandomToken() ); From 61a8ccbaee018250bc555d28285be98e99661f0d Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 13 Dec 2023 16:49:52 -0500 Subject: [PATCH 37/39] Fix GO recommendations Make the size of terms more accurate by calculating only the number of distinct genes among its descendants and itself. Fix a bug in the recommendation that was caused by a term appearing in its own set of descendants and thus being filtering out. Remove the minSize argument, we now only recommend term that have at least one novel gene. Apply the gene size limit when saving a profile by ignoring terms that exceed the limit. Combine genes and TIER3 genes to account for removed genes in an unsaved profile Fix all broken recommendation tests. Make the minimum overlap configurable --- .../rdp/controllers/UserController.java | 35 ++++- .../GeneOntologyTermInfoRepository.java | 35 +++++ .../ubc/pavlab/rdp/services/GOService.java | 4 + .../pavlab/rdp/services/GOServiceImpl.java | 26 ++- .../ubc/pavlab/rdp/services/UserService.java | 6 +- .../pavlab/rdp/services/UserServiceImpl.java | 81 ++++++++-- .../rdp/settings/ApplicationSettings.java | 5 + src/main/resources/application.properties | 6 +- src/main/resources/messages.properties | 8 + src/main/resources/static/js/model.js | 6 +- .../rdp/controllers/UserControllerTest.java | 22 +-- .../rdp/services/UserServiceImplTest.java | 71 +++++---- .../UserServiceTermRecommendationTest.java | 148 ++++++++++++++++++ src/test/resources/application.properties | 4 +- 14 files changed, 387 insertions(+), 70 deletions(-) create mode 100644 src/test/java/ubc/pavlab/rdp/services/UserServiceTermRecommendationTest.java diff --git a/src/main/java/ubc/pavlab/rdp/controllers/UserController.java b/src/main/java/ubc/pavlab/rdp/controllers/UserController.java index 8a5625da..6eb60538 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/UserController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/UserController.java @@ -3,14 +3,17 @@ import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Value; import lombok.extern.apachecommons.CommonsLog; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.InvalidMediaTypeException; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; import org.springframework.security.access.annotation.Secured; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Controller; @@ -498,23 +501,45 @@ public Object getTermsForTaxon( @PathVariable Integer taxonId, return goIds.stream().collect( toNullableMap( identity(), goId -> goService.getTerm( goId ) == null ? null : userService.convertTerm( user, taxon, goService.getTerm( goId ) ) ) ); } + @Value + static class RecommendedTermsModel { + /** + * List of recommended GO terms. + */ + public Collection recommendedTerms; + /** + * Feedback to be displayed or null if no feedback is available. + */ + @Nullable + public String feedback; + } + @ResponseBody @GetMapping(value = "/user/taxon/{taxonId}/term/recommend", produces = MediaType.APPLICATION_JSON_VALUE) public Object getRecommendedTermsForTaxon( @PathVariable Integer taxonId, - @RequestParam(required = false) List geneIds ) { + @RequestParam(required = false) List geneIds, + Locale locale ) { Taxon taxon = taxonService.findById( taxonId ); if ( taxon == null ) { return ResponseEntity.notFound().build(); } - Set genes; + User user = userService.findCurrentUser(); + + Collection recommendedTerms; + List feedback = new ArrayList<>(); if ( geneIds != null ) { - genes = new HashSet<>( geneService.load( geneIds ) ); + Set genes = new HashSet<>( geneService.load( geneIds ) ); + recommendedTerms = userService.recommendTerms( user, genes, taxon, feedback ); } else { - genes = Collections.emptySet(); + recommendedTerms = userService.recommendTerms( user, taxon, feedback ); } - return userService.recommendTerms( userService.findCurrentUser(), genes, taxon ); + String formattedFeedback = feedback.isEmpty() ? null : feedback.stream() + .map( f -> messageSource.getMessage( f, locale ) ) + .collect( Collectors.joining( "\n" ) ); + + return new RecommendedTermsModel( recommendedTerms, formattedFeedback ); } private Set getManualTiers() { diff --git a/src/main/java/ubc/pavlab/rdp/repositories/GeneOntologyTermInfoRepository.java b/src/main/java/ubc/pavlab/rdp/repositories/GeneOntologyTermInfoRepository.java index 06a1f122..d5e14d5c 100644 --- a/src/main/java/ubc/pavlab/rdp/repositories/GeneOntologyTermInfoRepository.java +++ b/src/main/java/ubc/pavlab/rdp/repositories/GeneOntologyTermInfoRepository.java @@ -6,6 +6,7 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import ubc.pavlab.rdp.model.GeneOntologyTermInfo; +import ubc.pavlab.rdp.model.Taxon; import java.util.*; import java.util.concurrent.locks.Lock; @@ -198,6 +199,40 @@ public long count() { } } + /** + * Count the number of terms for the given taxon; + */ + public long countByTaxon( Taxon taxon ) { + Lock lock = rwLock.readLock(); + try { + lock.lock(); + return termsByIdOrAlias.values().stream() + .distinct() + .map( GeneOntologyTermInfo::getDirectGeneIdsByTaxonId ) + .filter( m -> m.containsKey( taxon.getId() ) ) + .count(); + } finally { + lock.unlock(); + } + } + + /** + * Count the number of term-gene associations for the given taxon. + */ + public long countGeneAssociationsByTaxon( Taxon taxon ) { + Lock lock = rwLock.readLock(); + try { + lock.lock(); + return termsByIdOrAlias.values().stream() + .distinct() + .map( GeneOntologyTermInfo::getDirectGeneIdsByTaxonId ) + .mapToLong( m -> m.getOrDefault( taxon.getId(), Collections.emptyList() ).size() ) + .sum(); + } finally { + lock.unlock(); + } + } + @Override public void deleteById( String id ) { // FIXME: we should acquire a read lock here and promote it to a write lock if the element exists, but I don't diff --git a/src/main/java/ubc/pavlab/rdp/services/GOService.java b/src/main/java/ubc/pavlab/rdp/services/GOService.java index 1720e9ba..3e60fce5 100644 --- a/src/main/java/ubc/pavlab/rdp/services/GOService.java +++ b/src/main/java/ubc/pavlab/rdp/services/GOService.java @@ -24,6 +24,10 @@ public interface GOService { long count(); + long countByTaxon( Taxon taxon ); + + long countGeneAssociationsByTaxon( Taxon taxon ); + Collection getDescendants( GeneOntologyTermInfo entry ); Collection getAncestors( GeneOntologyTermInfo entry ); diff --git a/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java index a1c27dd1..34803cd7 100644 --- a/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/GOServiceImpl.java @@ -279,12 +279,12 @@ public List> search( String queryString, Taxo @Override public long getSizeInTaxon( GeneOntologyTermInfo t, Taxon taxon ) { - Collection descendants = getDescendants( t ); + Collection descendants = new HashSet<>( getDescendants( t ) ); descendants.add( t ); return descendants.stream() + .flatMap( term -> term.getDirectGeneIdsByTaxonId().getOrDefault( taxon.getId(), Collections.emptyList() ).stream() ) .distinct() - .mapToLong( term -> term.getDirectGeneIdsByTaxonId().getOrDefault( taxon.getId(), Collections.emptyList() ).size() ) - .sum(); + .count(); } @Override @@ -360,13 +360,23 @@ public long count() { return goRepository.count(); } + @Override + public long countByTaxon( Taxon taxon ) { + return goRepository.countByTaxon( taxon ); + } + + @Override + public long countGeneAssociationsByTaxon( Taxon taxon ) { + return goRepository.countGeneAssociationsByTaxon( taxon ); + } + @Override public Collection getDescendants( GeneOntologyTermInfo entry ) { StopWatch timer = StopWatch.createStarted(); Lock lock = rwLock.readLock(); try { lock.lock(); - return getDescendantsInternal( entry ); + return Collections.unmodifiableCollection( getDescendantsInternal( entry ) ); } finally { lock.unlock(); if ( timer.getTime( TimeUnit.MILLISECONDS ) > 1000 ) { @@ -386,6 +396,9 @@ private Set getDescendantsInternal( GeneOntologyTermInfo e results.add( child ); results.addAll( getDescendantsInternal( child ) ); } + if ( results.remove( entry ) ) { + log.warn( String.format( "%s is its own descendant, removing it to prevent cycles.", entry ) ); + } descendantsCache.put( entry, results ); return results; } @@ -479,7 +492,7 @@ public Collection getAncestors( GeneOntologyTermInfo term Lock lock = rwLock.readLock(); try { lock.lock(); - return getAncestorsInternal( term ); + return Collections.unmodifiableCollection( getAncestorsInternal( term ) ); } finally { lock.unlock(); } @@ -495,6 +508,9 @@ private Collection getAncestorsInternal( GeneOntologyTermI results.add( parent ); results.addAll( getAncestorsInternal( parent ) ); } + if ( results.remove( term ) ) { + log.warn( String.format( "%s is its own ancestor, removing it to prevent cycle.", term ) ); + } ancestorsCache.put( term, results ); return results; } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserService.java b/src/main/java/ubc/pavlab/rdp/services/UserService.java index 60d6fadf..6926de53 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserService.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserService.java @@ -1,7 +1,9 @@ package ubc.pavlab.rdp.services; +import org.springframework.context.MessageSourceResolvable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.lang.Nullable; import org.springframework.security.authentication.BadCredentialsException; import ubc.pavlab.rdp.exception.TokenException; import ubc.pavlab.rdp.model.*; @@ -161,12 +163,12 @@ public interface UserService { *

* The recommendation are based on the user's {@link TierType#MANUAL} gene set. */ - Collection recommendTerms( User user, Taxon taxon ); + Collection recommendTerms( User user, Taxon taxon, @Nullable List feedback ); /** * Recommend terms for a user using a supplied gene set which might differ from the user's. */ - Collection recommendTerms( User user, Set genes, Taxon taxon ); + Collection recommendTerms( User user, Set genes, Taxon taxon, @Nullable List feedback ); User updateTermsAndGenesInTaxon( User user, Taxon taxon, diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index 34e3884d..248bfd29 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -10,8 +10,11 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.lang.Nullable; import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PostFilter; @@ -556,49 +559,88 @@ public Collection convertTerms( User user, Taxon taxon, Collection recommendTerms( @NonNull User user, @NonNull Taxon taxon ) { - return recommendTerms( user, user.getGenesByTaxonAndTier( taxon, getManualTiers() ), taxon, 10, applicationSettings.getGoTermSizeLimit(), 2 ); + public Collection recommendTerms( @NonNull User user, Taxon taxon, List feedback ) { + return recommendTerms( user, user.getGenesByTaxonAndTier( taxon, getManualTiers() ), taxon, applicationSettings.getGoTermSizeLimit(), applicationSettings.getGoTermMinOverlap(), feedback ); } @Override @PostFilter("hasPermission(filterObject, 'read')") - public Collection recommendTerms( User user, Set genes, Taxon taxon ) { - return recommendTerms( user, genes, taxon, 10, applicationSettings.getGoTermSizeLimit(), 2 ); + public Collection recommendTerms( User user, Set genes, Taxon taxon, List feedback ) { + return recommendTerms( user, genes, taxon, applicationSettings.getGoTermSizeLimit(), applicationSettings.getGoTermMinOverlap(), feedback ); } - /** * This is only meant for testing purposes; refrain from using in actual code. */ @PostFilter("hasPermission(filterObject, 'read')") - Collection recommendTerms( @NonNull User user, @NonNull Taxon taxon, long minSize, long maxSize, long minFrequency ) { - return recommendTerms( user, user.getGenesByTaxonAndTier( taxon, getManualTiers() ), taxon, minSize, maxSize, minFrequency ); + Collection recommendTerms( @NonNull User user, @NonNull Taxon taxon, long maxSize, long minFrequency ) { + return recommendTerms( user, user.getGenesByTaxonAndTier( taxon, getManualTiers() ), taxon, maxSize, minFrequency, null ); } - private Collection recommendTerms( @NonNull User user, Set genes, @NonNull Taxon taxon, long minSize, long maxSize, long minFrequency ) { + private Collection recommendTerms( @NonNull User user, Set genes, Taxon taxon, long maxSize, long minFrequency, @Nullable List feedback ) { // terms already associated to user within the taxon Set userTermGoIds = user.getUserTerms().stream() .filter( ut -> ut.getTaxon().equals( taxon ) ) .map( UserTerm::getGoId ) .collect( Collectors.toSet() ); + if ( genes.size() < minFrequency ) { + addFeedback( "UserService.recommendTerms.tooFewGenes", new String[]{ String.valueOf( minFrequency ) }, feedback ); + return Collections.emptySet(); + } + + // include TIER3 genes when recommending terms with novel genes + HashSet allGenes = new HashSet<>( genes ); + allGenes.addAll( user.getGenesByTaxonAndTier( taxon, EnumSet.of( TierType.TIER3 ) ) ); + Map sizeOfAllGenes = goService.termFrequencyMap( allGenes ); + // Then keep only those terms not already added and with the highest frequency Set topResults = goService.termFrequencyMap( genes ).entrySet().stream() - .filter( e -> minFrequency < 0 || e.getValue() >= minFrequency ) - .filter( e -> minSize < 0 || goService.getSizeInTaxon( e.getKey(), taxon ) >= minSize ) - .filter( e -> maxSize < 0 || goService.getSizeInTaxon( e.getKey(), taxon ) <= maxSize ) + .filter( e -> e.getValue() >= minFrequency ) .filter( e -> !userTermGoIds.contains( e.getKey().getGoId() ) ) + .filter( e -> { + long numberOfGenesInTaxon = goService.getSizeInTaxon( e.getKey(), taxon ); + // never recommend terms that have more than the GO term size limit + if ( maxSize >= 0 && numberOfGenesInTaxon > maxSize ) { + return false; + } + long numberOfUserGenesInTaxon = sizeOfAllGenes.getOrDefault( e.getKey(), 0L ); + // the difference between the size and frequency from the gene set is the number of new genes that + // the term would add to the user profile + long numberOfNewGenesInTaxon = numberOfGenesInTaxon - numberOfUserGenesInTaxon; + // ensure that at least 1 novel gene is being added + return numberOfNewGenesInTaxon > 0; + } ) .map( Map.Entry::getKey ) .collect( Collectors.toSet() ); + if ( topResults.isEmpty() ) { + // check for some common causes + if ( goService.countByTaxon( taxon ) == 0 ) { + addFeedback( "UserService.recommendTerms.noTermsInTaxon", new String[]{ taxon.getCommonName() }, feedback ); + return Collections.emptySet(); + } else if ( goService.countGeneAssociationsByTaxon( taxon ) == 0 ) { + addFeedback( "UserService.recommendTerms.noGeneAssociationsInTaxon", new String[]{ taxon.getCommonName() }, feedback ); + return Collections.emptySet(); + } else { + addFeedback( "UserService.recommendTerms.noResults", null, feedback ); + return Collections.emptySet(); + } + } + // Keep only leafiest of remaining terms (keep if it has no descendants in results) return topResults.stream() .filter( term -> Collections.disjoint( topResults, goService.getDescendants( term ) ) ) - .filter( term -> goService.getSizeInTaxon( term, taxon ) <= applicationSettings.getGoTermSizeLimit() ) .map( term -> convertTerm( user, taxon, term ) ) .collect( Collectors.toSet() ); } + private void addFeedback( String code, @Nullable Object[] args, @Nullable List feedback ) { + if ( feedback != null ) { + feedback.add( new DefaultMessageSourceResolvable( new String[]{ code }, args, null ) ); + } + } + @Transactional @Override @PreAuthorize("hasPermission(#user, 'update')") @@ -629,8 +671,12 @@ public User updateTermsAndGenesInTaxon( User user, .collect( Collectors.toMap( Gene::getGeneId, identity() ) ); // add calculated genes from terms + long maxSize = applicationSettings.getGoTermSizeLimit(); Map userGenesFromTerms = goTerms.stream() - .flatMap( term -> goService.getGenesInTaxon( term, taxon ).stream() ) + .map( term -> goService.getGenesInTaxon( term, taxon ) ) + // never add genes from terms that exceed the GO limit (those are never recommended) + .filter( c -> maxSize < 0 || c.size() <= maxSize ) + .flatMap( Collection::stream ) .distinct() // terms might refer to the same gene .map( geneInfoService::load ) .filter( Objects::nonNull ) @@ -659,7 +705,7 @@ public User updateTermsAndGenesInTaxon( User user, // update frequency and size as those have likely changed with new genes for ( UserTerm userTerm : user.getUserTerms() ) { GeneOntologyTermInfo cachedTerm = goService.getTerm( userTerm.getGoId() ); - userTerm.setFrequency( computeTermFrequencyInTaxon( user, cachedTerm, taxon ) ); + userTerm.setFrequency( computeTermFrequencyInTaxon( user, userTerm, taxon ) ); userTerm.setSize( goService.getSizeInTaxon( cachedTerm, taxon ) ); } @@ -684,7 +730,12 @@ public long computeTermOverlaps( UserTerm userTerm, Collection genes ) */ @Override public long computeTermFrequencyInTaxon( User user, GeneOntologyTerm term, Taxon taxon ) { - Set geneIds = new HashSet<>( goService.getGenes( goService.getTerm( term.getGoId() ) ) ); + GeneOntologyTermInfo termInfo = goService.getTerm( term.getGoId() ); + if ( termInfo == null ) { + log.warn( String.format( "Could not find a term info for %s, returning zero for the frequency.", term.getGoId() ) ); + return 0L; + } + Set geneIds = new HashSet<>( goService.getGenesInTaxon( termInfo, taxon ) ); return user.getGenesByTaxonAndTier( taxon, getManualTiers() ).stream() .map( UserGene::getGeneId ) .filter( geneIds::contains ) diff --git a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java index 4b2293cb..b2254bcf 100644 --- a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java +++ b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java @@ -263,6 +263,11 @@ public static class OntologySettings { private Resource faqFile; private boolean sendEmailOnRegistration; + /** + * Minimum overlap with TIER1 or TIER2 genes for recommending a term. + */ + @Min(1) + private long goTermMinOverlap; /** * Maximum number of GO terms. */ diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c87467b7..839a246b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -94,8 +94,10 @@ rdp.settings.cache.organ-file=http://purl.obolibrary.org/obo/uberon.obo # Send email to admin-email whenever there is a new registration rdp.settings.send-email-on-registration=false -# Maximum number of genes a term can have associated with it -# and still be available to add to a profile. +# Minimum overlap with TIER1 or TIER2 genes for recommending a term. +rdp.settings.go-term-min-overlap=2 +# Maximum number of genes a term can have associated with it and be recommended or have their genes added as TIER3 to a +# profile rdp.settings.go-term-size-limit=50 # Tiers diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index fea1492e..80d5e769 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -116,6 +116,14 @@ AdminController.SimpleOntologyForm.ontologyTerms.emptyGroupNotAllowed=Grouping t AdminController.DeleteOntologyForm.ontologyNameConfirmation.doesNotMatchOntologyName=The confirmation does not match the ontology name. +# All the following message need to be +UserService.recommendTerms.tooFewGenes=too few genes were supplied; you need at least {0} genes to get recommendations +# {0} contains the taxon common name +UserService.recommendTerms.noTermsInTaxon=GO terms are not available for {0} +# {0} contains the taxon common name +UserService.recommendTerms.noGeneAssociationsInTaxon=GO term to gene associations are not available for {0} +UserService.recommendTerms.noResults=no terms meet the requirements; try adding more genes first + # {0} contains the site shortname ApiConfig.title={0} RESTful API # {0} contains the site shortname diff --git a/src/main/resources/static/js/model.js b/src/main/resources/static/js/model.js index 93d501aa..59e1c210 100644 --- a/src/main/resources/static/js/model.js +++ b/src/main/resources/static/js/model.js @@ -419,18 +419,18 @@ var spinner = $(this).find('.spinner'); spinner.toggleClass("d-none", false); recommendMessage.classList.toggle('d-none', true); - var geneIds = geneTable.DataTable().column(0).data().toArray(); + var geneIds = geneTable.DataTable().column(1).data().toArray(); $.getJSON(window.contextPath + "/user/taxon/" + encodeURIComponent(window.currentTaxonId) + "/term/recommend", { geneIds: geneIds }).done(function (data) { - var addedTerms = addTermRow(data); + var addedTerms = addTermRow(data.recommendedTerms); if (addedTerms > 0) { recommendMessage.textContent = 'Recommended ' + addedTerms + ' terms.'; recommendMessage.classList.toggle('alert-success', true); recommendMessage.classList.toggle('alert-danger', false); recommendMessage.removeAttribute('role'); } else { - recommendMessage.textContent = 'Could not recommend new terms. Try adding more genes first.'; + recommendMessage.textContent = 'Could not recommend new terms' + (data.feedback ? ': ' + data.feedback : '') + '.'; recommendMessage.classList.toggle('alert-success', false); recommendMessage.classList.toggle('alert-danger', true); recommendMessage.setAttribute('role', 'alert'); diff --git a/src/test/java/ubc/pavlab/rdp/controllers/UserControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/UserControllerTest.java index 5b1642ac..b05124af 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/UserControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/UserControllerTest.java @@ -549,24 +549,26 @@ public void givenLoggedIn_whenRecommendTerms_thenReturnJson() when( userService.findCurrentUser() ).thenReturn( user ); - when( userService.recommendTerms( any(), any(), eq( taxon ) ) ).thenReturn( Sets.newSet( t1, t2 ) ); - when( userService.recommendTerms( any(), any(), eq( taxon2 ) ) ).thenReturn( Sets.newSet( t3, t4 ) ); + when( userService.recommendTerms( any(), eq( taxon ), any() ) ).thenReturn( Sets.newSet( t1, t2 ) ); + when( userService.recommendTerms( any(), eq( taxon2 ), any() ) ).thenReturn( Sets.newSet( t3, t4 ) ); mvc.perform( get( "/user/taxon/1/term/recommend" ) .contentType( MediaType.APPLICATION_JSON ) ) .andExpect( status().isOk() ) - .andExpect( jsonPath( "$", hasSize( 2 ) ) ) - .andExpect( jsonPath( "$[*].goId" ).value( containsInAnyOrder( t1.getGoId(), t2.getGoId() ) ) ) - .andExpect( jsonPath( "$[*].taxon.id" ).value( contains( taxon.getId(), taxon.getId() ) ) ); - verify( userService ).recommendTerms( eq( user ), any(), eq( taxon ) ); + .andExpect( jsonPath( "$.recommendedTerms", hasSize( 2 ) ) ) + .andExpect( jsonPath( "$.recommendedTerms[*].goId" ).value( containsInAnyOrder( t1.getGoId(), t2.getGoId() ) ) ) + .andExpect( jsonPath( "$.recommendedTerms[*].taxon.id" ).value( contains( taxon.getId(), taxon.getId() ) ) ) + .andExpect( jsonPath( "$.feedback" ).value( nullValue() ) ); + verify( userService ).recommendTerms( eq( user ), eq( taxon ), any() ); mvc.perform( get( "/user/taxon/2/term/recommend" ) .contentType( MediaType.APPLICATION_JSON ) ) .andExpect( status().isOk() ) - .andExpect( jsonPath( "$" ).value( hasSize( 2 ) ) ) - .andExpect( jsonPath( "$[*].goId" ).value( containsInAnyOrder( t3.getGoId(), t4.getGoId() ) ) ) - .andExpect( jsonPath( "$[*].taxon.id" ).value( contains( taxon2.getId(), taxon2.getId() ) ) ); - verify( userService ).recommendTerms( eq( user ), any(), eq( taxon2 ) ); + .andExpect( jsonPath( "$.recommendedTerms" ).value( hasSize( 2 ) ) ) + .andExpect( jsonPath( "$.recommendedTerms[*].goId" ).value( containsInAnyOrder( t3.getGoId(), t4.getGoId() ) ) ) + .andExpect( jsonPath( "$.recommendedTerms[*].taxon.id" ).value( contains( taxon2.getId(), taxon2.getId() ) ) ) + .andExpect( jsonPath( "$.feedback" ).value( nullValue() ) ); + verify( userService ).recommendTerms( eq( user ), eq( taxon2 ), any() ); } // POST diff --git a/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java b/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java index 4be56c3e..2cc4717e 100644 --- a/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java +++ b/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java @@ -1165,14 +1165,16 @@ private void assertThatUserGenesAreEqualTo( User user, Taxon taxon, Map found = userService.recommendTerms( user, taxon ); - assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 1 ), toGOId( 7 ), toGOId( 8 ) ); + Collection found = userService.recommendTerms( user, taxon, null ); + assertThat( found ) + .extracting( GeneOntologyTerm::getGoId ) + .containsExactlyInAnyOrder( "GO:0000007", "GO:0000000", "GO:0000002", "GO:0000004", "GO:0000006", + "GO:0000008", "GO:0000001", "GO:0000003", "GO:0000005", "GO:0000099" ); } @Test @@ -1181,37 +1183,48 @@ public void recommendTerms_whenMinSizeLimited_thenReturnBestLimitedResultsOnly() User user = createUser( 1 ); Taxon taxon = createTaxon( 1 ); - Collection found = userService.recommendTerms( user, taxon, 12, -1, -1 ); - assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 1 ), toGOId( 7 ), toGOId( 8 ) ); + Collection found = userService.recommendTerms( user, taxon, -1, 0 ); + assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ) + .containsExactlyInAnyOrder( "GO:0000008", "GO:0000004", "GO:0000002", "GO:0000099", "GO:0000000", "GO:0000007", "GO:0000005", "GO:0000003", "GO:0000006", "GO:0000001" ); - found = userService.recommendTerms( user, taxon, 20, -1, -1 ); + found = userService.recommendTerms( user, taxon, -1, 1 ); assertThat( found ).isEmpty(); } @Test - @Ignore public void recommendTerms_whenMaxSizeLimited_thenReturnBestLimitedResultsOnly() { setUpRecommendTermsMocks(); User user = createUser( 1 ); Taxon taxon = createTaxon( 1 ); - Collection found = userService.recommendTerms( user, taxon, -1, 12, -1 ); - assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 0 ), toGOId( 4 ), toGOId( 6 ) ); - - found = userService.recommendTerms( user, taxon, -1, 1, -1 ); + Collection found = userService.recommendTerms( user, taxon, 12, 0 ); + assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ) + .containsExactlyInAnyOrder( "GO:0000008", + "GO:0000006", + "GO:0000003", + "GO:0000001", + "GO:0000007", + "GO:0000005", + "GO:0000000", + "GO:0000099", + "GO:0000004", + "GO:0000002" ); + + found = userService.recommendTerms( user, taxon, 1, 0 ); assertThat( found ).isEmpty(); } @Test + @Ignore public void recommendTerms_whenFrequencyLimited_thenReturnBestLimitedResultsOnly() { setUpRecommendTermsMocks(); User user = createUser( 1 ); Taxon taxon = createTaxon( 1 ); - Collection found = userService.recommendTerms( user, taxon, -1, -1, 3 ); + Collection found = userService.recommendTerms( user, taxon, -1, 3 ); assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 1 ), toGOId( 7 ), toGOId( 8 ) ); - found = userService.recommendTerms( user, taxon, -1, -1, 4 ); + found = userService.recommendTerms( user, taxon, -1, 4 ); assertThat( found ).isEmpty(); } @@ -1222,10 +1235,11 @@ public void recommendTerms_whenFrequencyLimitedAndSizeLimited_thenReturnBestLimi User user = createUser( 1 ); Taxon taxon = createTaxon( 1 ); - Collection found = userService.recommendTerms( user, taxon, 11, 12, 2 ); - assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 0 ), toGOId( 4 ), toGOId( 6 ) ); + Collection found = userService.recommendTerms( user, taxon, 12, 2 ); + assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ) + .containsExactlyInAnyOrder( "GO:0000007", "GO:0000000", "GO:0000004", "GO:0000006", "GO:0000008", "GO:0000001" ); - found = userService.recommendTerms( user, taxon, 1, 11, 2 ); + found = userService.recommendTerms( user, taxon, 11, 2 ); assertThat( found ).isEmpty(); } @@ -1236,15 +1250,15 @@ public void recommendTerms_whenRedundantTerms_thenReturnOnlyMostSpecific() { User user = createUser( 1 ); Taxon taxon = createTaxon( 1 ); - Collection found = userService.recommendTerms( user, taxon, 11, 11, 1 ); - assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 2 ), toGOId( 3 ), toGOId( 5 ), toGOId( 99 ) ); + Collection found = userService.recommendTerms( user, taxon, 11, 1 ); + assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ) + .containsExactlyInAnyOrder( "GO:0000006", "GO:0000002", "GO:0000004", "GO:0000000", "GO:0000099", "GO:0000003", "GO:0000005" ); - found = userService.recommendTerms( user, taxon, 1, 11, 2 ); + found = userService.recommendTerms( user, taxon, 11, 2 ); assertThat( found ).isEmpty(); } @Test - @Ignore public void recommendTerms_whenUserHasSomeTopTerms_thenReturnNewBestResultsOnly() { setUpRecommendTermsMocks(); @@ -1253,8 +1267,10 @@ public void recommendTerms_whenUserHasSomeTopTerms_thenReturnNewBestResultsOnly( user.getUserTerms().add( createUserTerm( 1, user, createTerm( toGOId( 1 ) ), taxon ) ); - Collection found = userService.recommendTerms( user, taxon ); - assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 7 ), toGOId( 8 ) ); + Collection found = userService.recommendTerms( user, taxon, null ); + assertThat( found ).extracting( GeneOntologyTerm::getGoId ) + .containsExactlyInAnyOrder( "GO:0000000", "GO:0000099", "GO:0000007", "GO:0000004", + "GO:0000002", "GO:0000008", "GO:0000006", "GO:0000005", "GO:0000003" ); } @Test @@ -1268,8 +1284,9 @@ public void recommendTerms_whenUserHasAllTopTerms_thenReturnNextBestResultsOnly( user.getUserTerms().add( createUserTerm( 2, user, createTerm( toGOId( 7 ) ), taxon ) ); user.getUserTerms().add( createUserTerm( 3, user, createTerm( toGOId( 8 ) ), taxon ) ); - Collection found = userService.recommendTerms( user, taxon ); - assertThat( found.stream().map( GeneOntologyTerm::getGoId ).collect( Collectors.toList() ) ).containsExactlyInAnyOrder( toGOId( 0 ), toGOId( 4 ), toGOId( 6 ) ); + Collection found = userService.recommendTerms( user, taxon, null ); + assertThat( found ).extracting( GeneOntologyTerm::getGoId ) + .containsExactlyInAnyOrder( "GO:0000003", "GO:0000005", "GO:0000000", "GO:0000099", "GO:0000002", "GO:0000004", "GO:0000006" ); } @Test @@ -1279,7 +1296,7 @@ public void recommendTerms_whenUserHasNoGenes_thenReturnEmpty() { User user = createUser( 1 ); Taxon taxon = createTaxon( 1 ); - Collection found = userService.recommendTerms( user, taxon, -1, -1, -1 ); + Collection found = userService.recommendTerms( user, taxon, -1, -1 ); assertThat( found ).isEmpty(); } @@ -1288,7 +1305,7 @@ public void recommendTerms_whenUserNull_thenThrowNullPointerException() { setUpRecommendTermsMocks(); Taxon taxon = createTaxon( 1 ); - Collection found = userService.recommendTerms( null, taxon, -1, -1, -1 ); + Collection found = userService.recommendTerms( null, taxon, -1, -1 ); assertThat( found ).isNull(); } @@ -1297,7 +1314,7 @@ public void recommendTerms_whenTaxonNull_thenThrowNullPointerException() { setUpRecommendTermsMocks(); User user = createUser( 1 ); - userService.recommendTerms( user, null, -1, -1, -1 ); + userService.recommendTerms( user, null, -1, -1 ); } diff --git a/src/test/java/ubc/pavlab/rdp/services/UserServiceTermRecommendationTest.java b/src/test/java/ubc/pavlab/rdp/services/UserServiceTermRecommendationTest.java new file mode 100644 index 00000000..091acb72 --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/services/UserServiceTermRecommendationTest.java @@ -0,0 +1,148 @@ +package ubc.pavlab.rdp.services; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import ubc.pavlab.rdp.model.GeneOntologyTerm; +import ubc.pavlab.rdp.model.Taxon; +import ubc.pavlab.rdp.model.User; +import ubc.pavlab.rdp.model.UserGene; +import ubc.pavlab.rdp.model.enums.TierType; +import ubc.pavlab.rdp.repositories.*; +import ubc.pavlab.rdp.security.SecureTokenChallenge; +import ubc.pavlab.rdp.settings.ApplicationSettings; +import ubc.pavlab.rdp.util.OBOParser; + +import javax.servlet.http.HttpServletRequest; +import java.security.SecureRandom; +import java.util.Collections; +import java.util.EnumSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * A tailored test to verify that GO recommendation work as expected. + * + * @author poirigui + */ +@RunWith(SpringRunner.class) +@TestPropertySource("classpath:application.properties") +public class UserServiceTermRecommendationTest { + + @TestConfiguration + static class TTCC { + + @Bean + public ApplicationSettings applicationSettings() { + ApplicationSettings applicationSettings = mock( ApplicationSettings.class ); + when( applicationSettings.getIsearch() ).thenReturn( new ApplicationSettings.InternationalSearchSettings() ); + when( applicationSettings.getGoTermSizeLimit() ).thenReturn( 50L ); + when( applicationSettings.getGoTermMinOverlap() ).thenReturn( 2L ); + when( applicationSettings.getEnabledTiers() ).thenReturn( EnumSet.allOf( TierType.class ) ); + ApplicationSettings.CacheSettings cacheSettings = new ApplicationSettings.CacheSettings(); + cacheSettings.setTermFile( "classpath:cache/go.obo" ); + cacheSettings.setAnnotationFile( new ClassPathResource( "cache/gene2go.gz" ) ); + when( applicationSettings.getCache() ).thenReturn( cacheSettings ); + return applicationSettings; + } + + @Bean + public GOService goService() { + return new GOServiceImpl(); + } + + @Bean + public GeneOntologyTermInfoRepository geneOntologyTermInfoRepository() { + return new GeneOntologyTermInfoRepository(); + } + + @Bean + public UserServiceImpl userService() { + return new UserServiceImpl(); + } + + @Bean + public OBOParser oboParser() { + return new OBOParser(); + } + + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(); + } + } + + @MockBean + private UserRepository userRepository; + @MockBean + private RoleRepository roleRepository; + @MockBean + private PasswordResetTokenRepository passwordResetTokenRepository; + @MockBean + private VerificationTokenRepository tokenRepository; + @MockBean + private BCryptPasswordEncoder bCryptPasswordEncoder; + @MockBean + private OrganInfoService organInfoService; + @MockBean + private ApplicationEventPublisher eventPublisher; + @MockBean + private AccessTokenRepository accessTokenRepository; + @MockBean + private MessageSource messageSource; + @MockBean + private GeneInfoService geneInfoService; + @MockBean + private PrivacyService privacyService; + @MockBean + private SecureRandom secureRandom; + @MockBean + private OntologyService ontologyService; + @MockBean + private SecureTokenChallenge secureTokenChallenge; + @MockBean + private TaxonService taxonService; + + + @Autowired + private UserServiceImpl userService; + @Autowired + private GOService goService; + + private Taxon taxon = new Taxon(); + + @Test + public void test() { + taxon.setId( 9606 ); + when( taxonService.findByActiveTrue() ).thenReturn( Collections.singleton( taxon ) ); + goService.updateGoTerms(); + User user = new User(); + user.getUserGenes().put( 1, createGene( "BRCA1", 672 ) ); + user.getUserGenes().put( 2, createGene( "BRCA2", 675 ) ); + assertThat( userService.recommendTerms( user, taxon, null ) ) + .extracting( GeneOntologyTerm::getGoId ) + .containsExactlyInAnyOrder( "GO:0000800", "GO:0006978" ); + } + + private UserGene createGene( String symbol, int geneId ) { + UserGene gene = new UserGene(); + gene.setSymbol( symbol ); + gene.setGeneId( geneId ); + gene.setTier( TierType.TIER1 ); + gene.setTaxon( taxon ); + return gene; + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 3a28dfb7..4034a3d9 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -70,4 +70,6 @@ rdp.settings.organs.enabled=true rdp.settings.enabled-tiers= rdp.settings.privacy.enabled-gene-levels= rdp.settings.search.enabled-search-modes=BY_GENE,BY_RESEARCHER -rdp.settings.isearch.auth-tokens= \ No newline at end of file +rdp.settings.isearch.auth-tokens= +rdp.settings.go-term-min-overlap=2 +rdp.settings.go-term-size-limit=50 \ No newline at end of file From c5f18e258315db1d20d30b90c424108928deb63a Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Thu, 14 Dec 2023 16:11:09 -0500 Subject: [PATCH 38/39] Document the GO term recommendation algorithm --- docs/customization.md | 23 +++++++++++++++++++ .../pavlab/rdp/services/UserServiceImpl.java | 11 +++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/customization.md b/docs/customization.md index 48910b67..26067cbf 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -116,6 +116,29 @@ where taxon_id = 10090; Every time new model systems are added to the application, they will have to be activated in this manner. +## GO term recommendation + +Users can receive recommended terms based on the TIER1 and TIER2 genes they have added to their profiles. + +The recommendation algorithm works as follows: + +1. Retrieve GO terms associated to all TIER1 and TIER2 genes +2. Retrieve all the descendants of these terms +3. For each term, compute how many TIER1 or TIER2 genes they are associated either directly or indirectly via their + descendants +4. Keep terms that are not already on the user profile and that mention at least 2 TIER1 or TIER2 genes +5. Exclude terms with more than 50 associated genes +6. Retain terms that have at least one novel gene that is not on the user's profile +7. Retain most specific terms if a given term and its descendant is recommended + +You can adjust the number of overlapping TIER1 or TIER2 genes and the maximum size of a GO term by setting the +following: + +```ini +rdp.settings.go-term-min-overlap=2 # new in 1.5.8 +rdp.settings.go-term-size-limit=50 +``` + ### Customizing taxon appearance (new in 1.5.5) By default, taxon are rendered using the common name in title case. The only exception is for *Homo sapiens* which diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index 248bfd29..fd93dbda 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -577,6 +577,17 @@ Collection recommendTerms( @NonNull User user, @NonNull Taxon taxon, l return recommendTerms( user, user.getGenesByTaxonAndTier( taxon, getManualTiers() ), taxon, maxSize, minFrequency, null ); } + /** + * Recommend terms to a given user. + * + * @param user user who receives recommendations + * @param genes genes to use for recommendation + * @param taxon taxon to restrict recommendations + * @param maxSize maximum number of genes a recommended term can be associated with + * @param minFrequency minimum number of overlaps between the genes and + * @param feedback feedback is appended in the form of {@link MessageSourceResolvable} if non-null + * @return the recommended terms for the given parameters + */ private Collection recommendTerms( @NonNull User user, Set genes, Taxon taxon, long maxSize, long minFrequency, @Nullable List feedback ) { // terms already associated to user within the taxon Set userTermGoIds = user.getUserTerms().stream() From aad48a9dde90705880a4c4cf5925e1ad3a27c136 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 15 Dec 2023 18:49:28 -0500 Subject: [PATCH 39/39] Update rdp_version for mkdocs --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 81996812..b9d65328 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,5 +14,5 @@ plugins: markdown_extensions: - admonition extra: - rdp_version: 1.5.5 + rdp_version: 1.5.8 git_ref: master