diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml index d0dc8ec6da..e37999f71d 100644 --- a/develop/docker-compose.yml +++ b/develop/docker-compose.yml @@ -29,6 +29,7 @@ services: - DOCKER_RUNNER=true - TEXLIVE_IMAGE=texlive-full # docker build texlive -t texlive-full - COMPILES_HOST_DIR=${PWD}/compiles + - OUTPUT_HOST_DIR=${PWD}/output user: root volumes: - ${PWD}/compiles:/overleaf/services/clsi/compiles diff --git a/docker-compose.yml b/docker-compose.yml index 08d6db6fe7..2d43c2db18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,9 +75,13 @@ services: ## Sandboxed Compiles: https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles SANDBOXED_COMPILES: 'true' - SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true' ### Bind-mount source for /var/lib/overleaf/data/compiles inside the container. - SANDBOXED_COMPILES_HOST_DIR: '/home/user/sharelatex_data/data/compiles' + SANDBOXED_COMPILES_HOST_DIR_COMPILES: '/home/user/sharelatex_data/data/compiles' + ### Bind-mount source for /var/lib/overleaf/data/output inside the container. + SANDBOXED_COMPILES_HOST_DIR_OUTPUT: '/home/user/sharelatex_data/data/output' + ### Backwards compatibility (before Server Pro 5.5) + DOCKER_RUNNER: 'true' + SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true' ## Works with test LDAP server shown at bottom of docker compose # OVERLEAF_LDAP_URL: 'ldap://ldap:389' diff --git a/libraries/access-token-encryptor/buildscript.txt b/libraries/access-token-encryptor/buildscript.txt index 967c3063ad..36fd724a80 100644 --- a/libraries/access-token-encryptor/buildscript.txt +++ b/libraries/access-token-encryptor/buildscript.txt @@ -7,4 +7,4 @@ access-token-encryptor --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/fetch-utils/buildscript.txt b/libraries/fetch-utils/buildscript.txt index 716d3f0d5c..a158079aee 100644 --- a/libraries/fetch-utils/buildscript.txt +++ b/libraries/fetch-utils/buildscript.txt @@ -7,4 +7,4 @@ fetch-utils --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/fetch-utils/index.js b/libraries/fetch-utils/index.js index 643dcc752b..60e8180c7d 100644 --- a/libraries/fetch-utils/index.js +++ b/libraries/fetch-utils/index.js @@ -23,11 +23,11 @@ async function fetchJson(url, opts = {}) { } async function fetchJsonWithResponse(url, opts = {}) { - const { fetchOpts } = parseOpts(opts) + const { fetchOpts, detachSignal } = parseOpts(opts) fetchOpts.headers = fetchOpts.headers ?? {} fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json' - const response = await performRequest(url, fetchOpts) + const response = await performRequest(url, fetchOpts, detachSignal) if (!response.ok) { const body = await maybeGetResponseBody(response) throw new RequestFailedError(url, opts, response, body) @@ -53,8 +53,8 @@ async function fetchStream(url, opts = {}) { } async function fetchStreamWithResponse(url, opts = {}) { - const { fetchOpts, abortController } = parseOpts(opts) - const response = await performRequest(url, fetchOpts) + const { fetchOpts, abortController, detachSignal } = parseOpts(opts) + const response = await performRequest(url, fetchOpts, detachSignal) if (!response.ok) { const body = await maybeGetResponseBody(response) @@ -76,8 +76,8 @@ async function fetchStreamWithResponse(url, opts = {}) { * @throws {RequestFailedError} if the response has a failure status code */ async function fetchNothing(url, opts = {}) { - const { fetchOpts } = parseOpts(opts) - const response = await performRequest(url, fetchOpts) + const { fetchOpts, detachSignal } = parseOpts(opts) + const response = await performRequest(url, fetchOpts, detachSignal) if (!response.ok) { const body = await maybeGetResponseBody(response) throw new RequestFailedError(url, opts, response, body) @@ -108,9 +108,9 @@ async function fetchRedirect(url, opts = {}) { * @throws {RequestFailedError} if the response has a non redirect status code or missing Location header */ async function fetchRedirectWithResponse(url, opts = {}) { - const { fetchOpts } = parseOpts(opts) + const { fetchOpts, detachSignal } = parseOpts(opts) fetchOpts.redirect = 'manual' - const response = await performRequest(url, fetchOpts) + const response = await performRequest(url, fetchOpts, detachSignal) if (response.status < 300 || response.status >= 400) { const body = await maybeGetResponseBody(response) throw new RequestFailedError(url, opts, response, body) @@ -142,8 +142,8 @@ async function fetchString(url, opts = {}) { } async function fetchStringWithResponse(url, opts = {}) { - const { fetchOpts } = parseOpts(opts) - const response = await performRequest(url, fetchOpts) + const { fetchOpts, detachSignal } = parseOpts(opts) + const response = await performRequest(url, fetchOpts, detachSignal) if (!response.ok) { const body = await maybeGetResponseBody(response) throw new RequestFailedError(url, opts, response, body) @@ -178,13 +178,14 @@ function parseOpts(opts) { const abortController = new AbortController() fetchOpts.signal = abortController.signal + let detachSignal = () => {} if (opts.signal) { - abortOnSignal(abortController, opts.signal) + detachSignal = abortOnSignal(abortController, opts.signal) } if (opts.body instanceof Readable) { abortOnDestroyedRequest(abortController, fetchOpts.body) } - return { fetchOpts, abortController } + return { fetchOpts, abortController, detachSignal } } function setupJsonBody(fetchOpts, json) { @@ -208,6 +209,9 @@ function abortOnSignal(abortController, signal) { abortController.abort(signal.reason) } signal.addEventListener('abort', listener) + return () => { + signal.removeEventListener('abort', listener) + } } function abortOnDestroyedRequest(abortController, stream) { @@ -226,11 +230,12 @@ function abortOnDestroyedResponse(abortController, response) { }) } -async function performRequest(url, fetchOpts) { +async function performRequest(url, fetchOpts, detachSignal) { let response try { response = await fetch(url, fetchOpts) } catch (err) { + detachSignal() if (fetchOpts.body instanceof Readable) { fetchOpts.body.destroy() } @@ -239,6 +244,7 @@ async function performRequest(url, fetchOpts) { method: fetchOpts.method ?? 'GET', }) } + response.body.on('close', detachSignal) if (fetchOpts.body instanceof Readable) { response.body.on('close', () => { if (!fetchOpts.body.readableEnded) { diff --git a/libraries/fetch-utils/test/unit/FetchUtilsTests.js b/libraries/fetch-utils/test/unit/FetchUtilsTests.js index e9fd0ff231..691e90778d 100644 --- a/libraries/fetch-utils/test/unit/FetchUtilsTests.js +++ b/libraries/fetch-utils/test/unit/FetchUtilsTests.js @@ -1,6 +1,9 @@ const { expect } = require('chai') +const fs = require('node:fs') +const events = require('node:events') const { FetchError, AbortError } = require('node-fetch') const { Readable } = require('node:stream') +const { pipeline } = require('node:stream/promises') const { once } = require('node:events') const { TestServer } = require('./helpers/TestServer') const selfsigned = require('selfsigned') @@ -203,6 +206,31 @@ describe('fetch-utils', function () { ).to.be.rejectedWith(AbortError) expect(stream.destroyed).to.be.true }) + + it('detaches from signal on success', async function () { + const signal = AbortSignal.timeout(10_000) + for (let i = 0; i < 20; i++) { + const s = await fetchStream(this.url('/hello'), { signal }) + expect(events.getEventListeners(signal, 'abort')).to.have.length(1) + await pipeline(s, fs.createWriteStream('/dev/null')) + expect(events.getEventListeners(signal, 'abort')).to.have.length(0) + } + }) + + it('detaches from signal on error', async function () { + const signal = AbortSignal.timeout(10_000) + for (let i = 0; i < 20; i++) { + try { + await fetchStream(this.url('/500'), { signal }) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 500) + continue + throw err + } finally { + expect(events.getEventListeners(signal, 'abort')).to.have.length(0) + } + } + }) }) describe('fetchNothing', function () { @@ -391,9 +419,16 @@ async function* infiniteIterator() { async function abortOnceReceived(func, server) { const controller = new AbortController() const promise = func(controller.signal) + expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(1) await once(server.events, 'request-received') controller.abort() - return await promise + try { + return await promise + } finally { + expect(events.getEventListeners(controller.signal, 'abort')).to.have.length( + 0 + ) + } } async function expectRequestAborted(req) { diff --git a/libraries/logger/buildscript.txt b/libraries/logger/buildscript.txt index 7db4c0c4c3..afe93c28dc 100644 --- a/libraries/logger/buildscript.txt +++ b/libraries/logger/buildscript.txt @@ -7,4 +7,4 @@ logger --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/metrics/buildscript.txt b/libraries/metrics/buildscript.txt index 2919cb8335..74fcbdb6c6 100644 --- a/libraries/metrics/buildscript.txt +++ b/libraries/metrics/buildscript.txt @@ -7,4 +7,4 @@ metrics --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/mongo-utils/buildscript.txt b/libraries/mongo-utils/buildscript.txt index ee90b005fb..a4e4fe7802 100644 --- a/libraries/mongo-utils/buildscript.txt +++ b/libraries/mongo-utils/buildscript.txt @@ -7,4 +7,4 @@ mongo-utils --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/o-error/buildscript.txt b/libraries/o-error/buildscript.txt index b50683a5d6..81d3eb3252 100644 --- a/libraries/o-error/buildscript.txt +++ b/libraries/o-error/buildscript.txt @@ -7,4 +7,4 @@ o-error --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/object-persistor/buildscript.txt b/libraries/object-persistor/buildscript.txt index c74883c30d..9ca6929a03 100644 --- a/libraries/object-persistor/buildscript.txt +++ b/libraries/object-persistor/buildscript.txt @@ -7,4 +7,4 @@ object-persistor --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/overleaf-editor-core/buildscript.txt b/libraries/overleaf-editor-core/buildscript.txt index e49dc289bf..03b7f06791 100644 --- a/libraries/overleaf-editor-core/buildscript.txt +++ b/libraries/overleaf-editor-core/buildscript.txt @@ -7,4 +7,4 @@ overleaf-editor-core --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/promise-utils/buildscript.txt b/libraries/promise-utils/buildscript.txt index ea5a4a8ef3..51a6dad532 100644 --- a/libraries/promise-utils/buildscript.txt +++ b/libraries/promise-utils/buildscript.txt @@ -7,4 +7,4 @@ promise-utils --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/ranges-tracker/buildscript.txt b/libraries/ranges-tracker/buildscript.txt index 2b3aaa82d9..d112f852a7 100644 --- a/libraries/ranges-tracker/buildscript.txt +++ b/libraries/ranges-tracker/buildscript.txt @@ -7,4 +7,4 @@ ranges-tracker --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/redis-wrapper/buildscript.txt b/libraries/redis-wrapper/buildscript.txt index 311348bd7c..89de51417a 100644 --- a/libraries/redis-wrapper/buildscript.txt +++ b/libraries/redis-wrapper/buildscript.txt @@ -7,4 +7,4 @@ redis-wrapper --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/settings/buildscript.txt b/libraries/settings/buildscript.txt index a731e6eb1c..ed79480d31 100644 --- a/libraries/settings/buildscript.txt +++ b/libraries/settings/buildscript.txt @@ -7,4 +7,4 @@ settings --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/libraries/stream-utils/buildscript.txt b/libraries/stream-utils/buildscript.txt index 0a8776469f..ad7265549c 100644 --- a/libraries/stream-utils/buildscript.txt +++ b/libraries/stream-utils/buildscript.txt @@ -7,4 +7,4 @@ stream-utils --is-library=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/package-lock.json b/package-lock.json index 5f0eb0baf1..48f2da293a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "services/analytics", "services/chat", "services/clsi", + "services/clsi-cache", "services/clsi-perf", "services/contacts", "services/docstore", @@ -1264,10 +1265,11 @@ } }, "node_modules/@babel/cli": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.24.8.tgz", - "integrity": "sha512-isdp+G6DpRyKc+3Gqxy2rjzgF7Zj9K0mzLNnxz+E/fgeag8qT3vVulX4gY9dGO1q0y+0lUv6V3a+uhUzMzrwXg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.27.0.tgz", + "integrity": "sha512-bZfxn8DRxwiVzDO5CEeV+7IqXeCkzI4yYnrQbpwjT76CUyossQc6RYE7n+xfm0/2k40lPaCpW0FhxYs7EBAetw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "commander": "^6.2.0", @@ -1286,7 +1288,7 @@ }, "optionalDependencies": { "@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3", - "chokidar": "^3.4.0" + "chokidar": "^3.6.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" @@ -1333,22 +1335,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", + "@babel/generator": "^7.26.10", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -1370,14 +1372,14 @@ "dev": true }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", + "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -1387,25 +1389,13 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", - "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz", - "integrity": "sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1442,17 +1432,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.0.tgz", - "integrity": "sha512-GYM6BxeQsETc9mnct+nIIpf63SAyzvyYN7UB/IlTyd+MBg06afFGp0mIeUqGyWgS2mxad6vqbMrHVlaL3m70sQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz", + "integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-member-expression-to-functions": "^7.24.8", - "@babel/helper-optimise-call-expression": "^7.24.7", - "@babel/helper-replace-supers": "^7.25.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/traverse": "^7.25.0", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.27.0", "semver": "^6.3.1" }, "engines": { @@ -1463,13 +1454,14 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.2.tgz", - "integrity": "sha512-+wqVGP+DFmqwFD3EH6TMTfUNeqDehV3E/dl+Sd54eaXqm17tEUNbEIn4sVivVowbvUpOtIGxdo3GoXyDH9N/9g==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz", + "integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "regexpu-core": "^5.3.1", + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.2.0", "semver": "^6.3.1" }, "engines": { @@ -1480,10 +1472,11 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", + "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", @@ -1496,13 +1489,14 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", - "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.8" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1541,35 +1535,38 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", - "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.0.tgz", - "integrity": "sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-wrap-function": "^7.25.0", - "@babel/traverse": "^7.25.0" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1579,14 +1576,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz", - "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", + "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.24.8", - "@babel/helper-optimise-call-expression": "^7.24.7", - "@babel/traverse": "^7.25.0" + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -1595,27 +1593,15 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", - "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", - "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1649,28 +1635,29 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.0.tgz", - "integrity": "sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.0", - "@babel/types": "^7.25.0" + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -1692,12 +1679,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -1707,13 +1694,14 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz", - "integrity": "sha512-wUrcsxZg6rqBXG05HG1FPYgsP6EvwF4WpBbxIpWIIYnH8wG0gzx3yZY3dtEHas4sTAOGkbTsc9EGPxwff8lRoA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/traverse": "^7.25.3" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1723,12 +1711,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.0.tgz", - "integrity": "sha512-Bm4bH2qsX880b/3ziJ8KD711LT7z4u8CFudmjqle65AZj/HNUFhEf90dqYv6O86buWvSBmeQDjv0Tn2aF/bIBA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1738,12 +1727,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.0", - "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.25.0.tgz", - "integrity": "sha512-lXwdNZtTmeVOOFtwM/WDe7yg1PL8sYhRk/XH0FzbR2HDQ0xC+EnQ/JHeoMYSavtU115tnUk0q9CDyq8si+LMAA==", + "version": "7.25.9", + "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.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1753,14 +1743,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz", - "integrity": "sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1770,13 +1761,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.0.tgz", - "integrity": "sha512-tggFrk1AIShG/RUQbEwt2Tr/E+ObkfwrPjR6BjbRvsx24+PSjK8zrq0GWPNCjo8qpRx4DuJzlcvWJqlm+0h3kw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/traverse": "^7.25.0" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1834,69 +1826,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-export-namespace-from": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", - "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-flow": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.25.9.tgz", @@ -1913,12 +1842,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.7.tgz", - "integrity": "sha512-Ec3NRUMoi8gskrkBe3fNmEQfxDvY8bgfQpz6jlk/41kX9eUjvpyqWU7PBP/pLAvMaSQjbMNKJmvX57jP+M6bPg==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1928,12 +1858,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", - "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1954,25 +1885,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", - "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1981,42 +1901,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-object-rest-spread": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", @@ -2029,67 +1913,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz", - "integrity": "sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2115,12 +1946,13 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz", - "integrity": "sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2130,15 +1962,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.0.tgz", - "integrity": "sha512-uaIi2FdqzjpAMvVqvB51S42oC2JEVgh0LDsGfZVDysWE8LrJtQC2jvKmOqEYThKyB7bDEb7BP1GYWDm7tABA0Q==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", + "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-remap-async-to-generator": "^7.25.0", - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/traverse": "^7.25.0" + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.26.8" }, "engines": { "node": ">=6.9.0" @@ -2148,14 +1980,15 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz", - "integrity": "sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-remap-async-to-generator": "^7.24.7" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2165,12 +1998,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz", - "integrity": "sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", + "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -2180,12 +2014,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.0.tgz", - "integrity": "sha512-yBQjYoOjXlFv9nlXb3f1casSHOZkWr29NX+zChVanLg5Nc157CrbEX9D7hxxtTpuFy7Q0YzmmWfJxzvps4kXrQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz", + "integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -2195,13 +2030,14 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.7.tgz", - "integrity": "sha512-vKbfawVYayKcSeSR5YYzzyXvsDFWU2mD8U5TFeXtbCPLFUqe7GyCgvO6XDHzje862ODrOwy6WCPmKeWHbCFJ4w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2211,14 +2047,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz", - "integrity": "sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-class-static-block": "^7.14.5" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2228,16 +2064,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.0.tgz", - "integrity": "sha512-xyi6qjr/fYU304fiRwFbekzkqVJZ6A7hOjWZd+89FVcBqPV3S9Wuozz82xdpLspckeaafntbzglaW4pqpzvtSw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-replace-supers": "^7.25.0", - "@babel/traverse": "^7.25.0", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", "globals": "^11.1.0" }, "engines": { @@ -2248,13 +2085,14 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz", - "integrity": "sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/template": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2264,12 +2102,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz", - "integrity": "sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2279,13 +2118,14 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz", - "integrity": "sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2295,12 +2135,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz", - "integrity": "sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2310,13 +2151,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.0.tgz", - "integrity": "sha512-YLpb4LlYSc3sCUa35un84poXoraOiQucUTTu8X1j18JV+gNa8E0nyUf/CjZ171IRGr4jEguF+vzJU66QZhn29g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.0", - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2326,13 +2168,13 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz", - "integrity": "sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2342,13 +2184,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz", - "integrity": "sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", + "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2358,13 +2200,13 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz", - "integrity": "sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2390,13 +2232,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz", - "integrity": "sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2406,14 +2249,15 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.25.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.1.tgz", - "integrity": "sha512-TVVJVdW9RKMNgJJlLtHsKDTydjZAbwIsn6ySBPQaEAUU5+gVvlJt/9nRmqVbsV/IBanRjzWoaAQKLoamWVOUuA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/traverse": "^7.25.1" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2423,13 +2267,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz", - "integrity": "sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-json-strings": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2439,12 +2283,13 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.2.tgz", - "integrity": "sha512-HQI+HcTbm9ur3Z2DkO+jgESMAMcYLuN/A7NRw9juzxAezN9AvqvUTnpKP/9kkYANz6u7dFlAyOu44ejuGySlfw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2454,13 +2299,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz", - "integrity": "sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2470,12 +2315,13 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz", - "integrity": "sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2485,13 +2331,14 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz", - "integrity": "sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2501,14 +2348,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz", - "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", + "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-simple-access": "^7.24.7" + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2518,15 +2365,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.0.tgz", - "integrity": "sha512-YPJfjQPDXxyQWg/0+jHKj1llnY5f/R6a0p/vP4lPymxLu7Lvl4k2WMitqi08yxwQcCVUUdG9LCUj4TNEgAp3Jw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.0", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "@babel/traverse": "^7.25.0" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2536,13 +2384,14 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz", - "integrity": "sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2552,13 +2401,14 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz", - "integrity": "sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2568,12 +2418,13 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz", - "integrity": "sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2583,13 +2434,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz", - "integrity": "sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==", + "version": "7.26.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", + "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -2599,13 +2450,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz", - "integrity": "sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2630,15 +2481,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz", - "integrity": "sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.24.7" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2648,13 +2499,14 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz", - "integrity": "sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-replace-supers": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2664,13 +2516,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz", - "integrity": "sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2680,14 +2532,14 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz", - "integrity": "sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2697,12 +2549,13 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz", - "integrity": "sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2712,13 +2565,14 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.7.tgz", - "integrity": "sha512-COTCOkG2hn4JKGEKBADkA8WNb35TGkkRbI5iT845dB+NyqgO8Hn+ajPbSnIQznneJTa3d30scb6iz/DhH8GsJQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2728,15 +2582,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz", - "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2746,12 +2600,13 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz", - "integrity": "sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2776,12 +2631,13 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.7.tgz", - "integrity": "sha512-H/Snz9PFxKsS1JLI4dJLtnJgCJRoo0AUm3chP6NYr+9En1JMKloheEiLIhlp5MDVznWo+H3AAC1Mc8lmUEpsgg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz", + "integrity": "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2807,16 +2663,17 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.2.tgz", - "integrity": "sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz", + "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/plugin-syntax-jsx": "^7.24.7", - "@babel/types": "^7.25.2" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2826,12 +2683,45 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.24.7.tgz", - "integrity": "sha512-QG9EnzoGn+Qar7rxuW+ZOsbWOt56FvvI93xInqsZDC5fsekx1AlIO4KIJ5M+D0p0SqSH156EpmZyXq630B8OlQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz", + "integrity": "sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.24.7" + "@babel/plugin-transform-react-jsx": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2841,13 +2731,14 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.7.tgz", - "integrity": "sha512-PLgBVk3fzbmEjBJ/u8kFzOqS9tUeDjiaWud/rRym/yjCo/M9cASPlnrd2ZmmZpQT40fOOrvR8jh+n8jikrOhNA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz", + "integrity": "sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2857,12 +2748,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz", - "integrity": "sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz", + "integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-plugin-utils": "^7.26.5", "regenerator-transform": "^0.15.2" }, "engines": { @@ -2872,13 +2764,31 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz", - "integrity": "sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2888,12 +2798,13 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz", - "integrity": "sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2903,13 +2814,14 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz", - "integrity": "sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2919,12 +2831,13 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz", - "integrity": "sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2934,12 +2847,13 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz", - "integrity": "sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", + "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -2949,12 +2863,13 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz", - "integrity": "sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz", + "integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -2964,16 +2879,17 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.2.tgz", - "integrity": "sha512-lBwRvjSmqiMYe/pS0+1gggjJleUJi7NzjvQ1Fkqtt69hBa/0t1YuW/MLQMAPixfwaQOHUXsd6jeU3Z+vdGv3+A==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz", + "integrity": "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-create-class-features-plugin": "^7.25.0", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/plugin-syntax-typescript": "^7.24.7" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.27.0", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2983,12 +2899,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz", - "integrity": "sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2998,13 +2915,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz", - "integrity": "sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -3014,13 +2932,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz", - "integrity": "sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -3030,13 +2949,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.7.tgz", - "integrity": "sha512-2G8aAvF4wy1w/AGZkemprdGMRg5o6zPNhbHVImRz3lss55TYCBd6xStN19rt8XJHq20sqV0JbyWjOWwQRwV/wg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -3065,93 +2985,80 @@ "hasInstallScript": true }, "node_modules/@babel/preset-env": { - "version": "7.25.3", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.3.tgz", - "integrity": "sha512-QsYW7UeAaXvLPX9tdVliMJE7MD7M6MLYVTovRTIwhoYQVFHR1rM4wO8wqAezYi3/BpSD+NzVCZ69R6smWiIi8g==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.2", - "@babel/helper-compilation-targets": "^7.25.2", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-validator-option": "^7.24.8", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.3", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.0", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.0", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.0", + "@babel/compat-data": "^7.26.8", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", "@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.24.7", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@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", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.24.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.0", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoped-functions": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.24.7", - "@babel/plugin-transform-class-static-block": "^7.24.7", - "@babel/plugin-transform-classes": "^7.25.0", - "@babel/plugin-transform-computed-properties": "^7.24.7", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-dotall-regex": "^7.24.7", - "@babel/plugin-transform-duplicate-keys": "^7.24.7", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.0", - "@babel/plugin-transform-dynamic-import": "^7.24.7", - "@babel/plugin-transform-exponentiation-operator": "^7.24.7", - "@babel/plugin-transform-export-namespace-from": "^7.24.7", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-function-name": "^7.25.1", - "@babel/plugin-transform-json-strings": "^7.24.7", - "@babel/plugin-transform-literals": "^7.25.2", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-member-expression-literals": "^7.24.7", - "@babel/plugin-transform-modules-amd": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-modules-systemjs": "^7.25.0", - "@babel/plugin-transform-modules-umd": "^7.24.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-new-target": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-numeric-separator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-object-super": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-property-literals": "^7.24.7", - "@babel/plugin-transform-regenerator": "^7.24.7", - "@babel/plugin-transform-reserved-words": "^7.24.7", - "@babel/plugin-transform-shorthand-properties": "^7.24.7", - "@babel/plugin-transform-spread": "^7.24.7", - "@babel/plugin-transform-sticky-regex": "^7.24.7", - "@babel/plugin-transform-template-literals": "^7.24.7", - "@babel/plugin-transform-typeof-symbol": "^7.24.8", - "@babel/plugin-transform-unicode-escapes": "^7.24.7", - "@babel/plugin-transform-unicode-property-regex": "^7.24.7", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/plugin-transform-unicode-sets-regex": "^7.24.7", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.26.8", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.26.5", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.26.3", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.26.8", + "@babel/plugin-transform-typeof-symbol": "^7.26.7", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.4", + "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.37.1", + "core-js-compat": "^3.40.0", "semver": "^6.3.1" }, "engines": { @@ -3193,17 +3100,18 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.7.tgz", - "integrity": "sha512-AAH4lEkpmzFWrGVlHaxJB7RLH21uPQ9+He+eFLWHmF9IuFQVugz8eAsamaW0DXRrTfco5zj1wWtpdcXJUOfsag==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.26.3.tgz", + "integrity": "sha512-Nl03d6T9ky516DGK2YMxrTqvnpUW63TnJMOMonj+Zae0JiPC5BC9xPMSL6L8fiSpA5vP88qfygavVQvnLp+6Cw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.24.7", - "@babel/plugin-transform-react-jsx-development": "^7.24.7", - "@babel/plugin-transform-react-pure-annotations": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-transform-react-display-name": "^7.25.9", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/plugin-transform-react-jsx-development": "^7.25.9", + "@babel/plugin-transform-react-pure-annotations": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -3213,16 +3121,17 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.7.tgz", - "integrity": "sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.0.tgz", + "integrity": "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "@babel/plugin-syntax-jsx": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.24.7" + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", + "@babel/plugin-transform-typescript": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -3232,10 +3141,11 @@ } }, "node_modules/@babel/register": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.24.6.tgz", - "integrity": "sha512-WSuFCc2wCqMeXkz/i3yfAAsxwWflEgbVkZzivgAmXl/MxrXeoYFZOOPllbC8R8WTF7u61wSRQtDVZ1879cdu6w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.25.9.tgz", + "integrity": "sha512-8D43jXtGsYmEeDvm4MWHYUpWf8iiXgWYx3fW7E7Wb7Oe6FWqJPl5K6TuFW0dOwNZzEE5rjlaSJYH9JjrUKJszA==", "dev": true, + "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", "find-cache-dir": "^2.0.0", @@ -3250,16 +3160,11 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", - "dev": true - }, "node_modules/@babel/runtime": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz", - "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -3267,27 +3172,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/runtime-corejs2": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.16.7.tgz", - "integrity": "sha512-ec0BM0J/9M5Cncha++AlgvvDlk+uM+m6f7K0t74ClcYzsE8LgX4RstRreksMSCI82o3LJS//UswmA0pUWkJpqg==", - "dev": true, - "dependencies": { - "core-js": "^2.6.5", - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs2/node_modules/core-js": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.4 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.", - "dev": true, - "hasInstallScript": true - }, "node_modules/@babel/runtime-corejs3": { "version": "7.16.8", "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.16.8.tgz", @@ -3306,32 +3190,32 @@ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", + "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/generator": "^7.27.0", + "@babel/parser": "^7.27.0", + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -3340,9 +3224,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -4482,9 +4366,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", + "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", "cpu": [ "ppc64" ], @@ -4499,9 +4383,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", "cpu": [ "arm" ], @@ -4516,9 +4400,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", + "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", "cpu": [ "arm64" ], @@ -4533,9 +4417,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", + "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", "cpu": [ "x64" ], @@ -4550,9 +4434,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", + "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", "cpu": [ "arm64" ], @@ -4567,9 +4451,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", "cpu": [ "x64" ], @@ -4584,9 +4468,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", + "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", "cpu": [ "arm64" ], @@ -4601,9 +4485,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", + "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", "cpu": [ "x64" ], @@ -4618,9 +4502,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", + "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", "cpu": [ "arm" ], @@ -4635,9 +4519,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", + "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", "cpu": [ "arm64" ], @@ -4652,9 +4536,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", "cpu": [ "ia32" ], @@ -4669,9 +4553,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", + "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", "cpu": [ "loong64" ], @@ -4686,9 +4570,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", "cpu": [ "mips64el" ], @@ -4703,9 +4587,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", + "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", "cpu": [ "ppc64" ], @@ -4720,9 +4604,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", + "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", "cpu": [ "riscv64" ], @@ -4737,9 +4621,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", + "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", "cpu": [ "s390x" ], @@ -4754,9 +4638,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", + "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", "cpu": [ "x64" ], @@ -4771,9 +4655,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", "cpu": [ "arm64" ], @@ -4788,9 +4672,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", + "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", "cpu": [ "x64" ], @@ -4805,9 +4689,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", + "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", "cpu": [ "arm64" ], @@ -4822,9 +4706,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", + "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", "cpu": [ "x64" ], @@ -4839,9 +4723,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", "cpu": [ "x64" ], @@ -4856,9 +4740,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", "cpu": [ "arm64" ], @@ -4873,9 +4757,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", + "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", "cpu": [ "ia32" ], @@ -4890,9 +4774,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", + "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", "cpu": [ "x64" ], @@ -6342,6 +6226,7 @@ "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", "dev": true, + "license": "MIT", "peerDependencies": { "react": "*" } @@ -6654,9 +6539,9 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.1.tgz", - "integrity": "sha512-osjeBqMJ2lb/j/M8NCPjs1ylqWIcTRTycIhVB5pt6LgzgeRSb0YRZ7j9RfA8wIUrsr/medIuhVyonXRZWLyfdw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", + "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6700,10 +6585,11 @@ "dev": true }, "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", - "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", - "dev": true + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" }, "node_modules/@lezer/common": { "version": "1.2.3", @@ -7011,9 +6897,9 @@ ] }, "node_modules/@napi-rs/canvas": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.65.tgz", - "integrity": "sha512-YcFhXQcp+b2d38zFOJNbpyPHnIL7KAEkhJQ+UeeKI5IpE9B8Cpf/M6RiHPQXSsSqnYbrfFylnW49dyh2oeSblQ==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.68.tgz", + "integrity": "sha512-LQESrePLEBLvhuFkXx9jjBXRC2ClYsO5mqQ1m/puth5z9SOuM3N/B3vDuqnC3RJFktDktyK9khGvo7dTkqO9uQ==", "dev": true, "license": "MIT", "optional": true, @@ -7021,22 +6907,22 @@ "node": ">= 10" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.65", - "@napi-rs/canvas-darwin-arm64": "0.1.65", - "@napi-rs/canvas-darwin-x64": "0.1.65", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.65", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.65", - "@napi-rs/canvas-linux-arm64-musl": "0.1.65", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.65", - "@napi-rs/canvas-linux-x64-gnu": "0.1.65", - "@napi-rs/canvas-linux-x64-musl": "0.1.65", - "@napi-rs/canvas-win32-x64-msvc": "0.1.65" + "@napi-rs/canvas-android-arm64": "0.1.68", + "@napi-rs/canvas-darwin-arm64": "0.1.68", + "@napi-rs/canvas-darwin-x64": "0.1.68", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.68", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.68", + "@napi-rs/canvas-linux-arm64-musl": "0.1.68", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.68", + "@napi-rs/canvas-linux-x64-gnu": "0.1.68", + "@napi-rs/canvas-linux-x64-musl": "0.1.68", + "@napi-rs/canvas-win32-x64-msvc": "0.1.68" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.65.tgz", - "integrity": "sha512-ZYwqFYEKcT5Zr8lbiaJNJj/poLaeK2TncolY914r+gD2TJNeP7ZqvE7A2SX/1C9MB4E3DQEwm3YhL3WEf0x3MQ==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.68.tgz", + "integrity": "sha512-h1KcSR4LKLfRfzeBH65xMxbWOGa1OtMFQbCMVlxPCkN1Zr+2gK+70pXO5ktojIYcUrP6KDcOwoc8clho5ccM/w==", "cpu": [ "arm64" ], @@ -7051,9 +6937,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.65.tgz", - "integrity": "sha512-Pg1pfiJEyDIsX+V0QaJPRWvXbw5zmWAk3bivFCvt/5pwZb37/sT6E/RqPHT9NnqpDyKW6SriwY9ypjljysUA1Q==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.68.tgz", + "integrity": "sha512-/VURlrAD4gDoxW1GT/b0nP3fRz/fhxmHI/xznTq2FTwkQLPOlLkDLCvTmQ7v6LtGKdc2Ed6rvYpRan+JXThInQ==", "cpu": [ "arm64" ], @@ -7068,9 +6954,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.65.tgz", - "integrity": "sha512-3Tr+/HjdJN7Z/VKIcsxV2DvDIibZCExgfYTgljCkUSFuoI7iNkOE6Dc1Q6j212EB9PeO8KmfrViBqHYT6IwWkA==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.68.tgz", + "integrity": "sha512-tEpvGR6vCLTo1Tx9wmDnoOKROpw57wiCWwCpDOuVlj/7rqEJOUYr9ixW4aRJgmeGBrZHgevI0EURys2ER6whmg==", "cpu": [ "x64" ], @@ -7085,9 +6971,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.65.tgz", - "integrity": "sha512-3KP+dYObH7CVkZMZWwk1WX9jRjL+EKdQtD43H8MOI+illf+dwqLlecdQ4d9bQRIxELKJ8dyPWY4fOp/Ngufrdg==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.68.tgz", + "integrity": "sha512-U9xbJsumPOiAYeAFZMlHf62b9dGs2HJ6Q5xt7xTB0uEyPeurwhgYBWGgabdsEidyj38YuzI/c3LGBbSQB3vagw==", "cpu": [ "arm" ], @@ -7102,9 +6988,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.65.tgz", - "integrity": "sha512-Ka3StKz7Dq7kjTF3nNJCq43UN/VlANS7qGE3dWkn1d+tQNsCRy/wRmyt1TUFzIjRqcTFMQNRbgYq84+53UBA0A==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.68.tgz", + "integrity": "sha512-KFkn8wEm3mPnWD4l8+OUUkxylSJuN5q9PnJRZJgv15RtCA1bgxIwTkBhI/+xuyVMcHqON9sXq7cDkEJtHm35dg==", "cpu": [ "arm64" ], @@ -7119,9 +7005,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.65.tgz", - "integrity": "sha512-O4xMASm2JrmqYoiDyxVWi+z5C14H+oVEag2rZ5iIA67dhWqYZB+iO7wCFpBYRj31JPBR29FOsu6X9zL+DwBFdw==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.68.tgz", + "integrity": "sha512-IQzts91rCdOALXBWQxLZRCEDrfFTGDtNRJMNu+2SKZ1uT8cmPQkPwVk5rycvFpvgAcmiFiOSCp1aRrlfU8KPpQ==", "cpu": [ "arm64" ], @@ -7136,9 +7022,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.65.tgz", - "integrity": "sha512-dblWDaA59ZU8bPbkfM+riSke7sFbNZ70LEevUdI5rgiFEUzYUQlU34gSBzemTACj5rCWt1BYeu0GfkLSjNMBSw==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.68.tgz", + "integrity": "sha512-e9AS5UttoIKqXSmBzKZdd3NErSVyOEYzJfNOCGtafGk1//gibTwQXGlSXmAKuErqMp09pyk9aqQRSYzm1AQfBw==", "cpu": [ "riscv64" ], @@ -7153,9 +7039,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.65.tgz", - "integrity": "sha512-wsp+atutw13OJXGU3DDkdngtBDoEg01IuK5xMe0L6VFPV8maGkh17CXze078OD5QJOc6kFyw3DDscMLOPF8+oA==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.68.tgz", + "integrity": "sha512-Pa/I36VE3j57I3Obhrr+J48KGFfkZk2cJN/2NmW/vCgmoF7kCP6aTVq5n+cGdGWLd/cN9CJ9JvNwEoMRDghu0g==", "cpu": [ "x64" ], @@ -7170,9 +7056,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.65.tgz", - "integrity": "sha512-odX+nN+IozWzhdj31INcHz3Iy9+EckNw+VqsZcaUxZOTu7/3FmktRNI6aC1qe5minZNv1m05YOS1FVf7fvmjlA==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.68.tgz", + "integrity": "sha512-9c6rkc5195wNxuUHJdf4/mmnq433OQey9TNvQ9LspJazvHbfSkTij8wtKjASVQsJyPDva4fkWOeV/OQ7cLw0GQ==", "cpu": [ "x64" ], @@ -7187,9 +7073,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.65.tgz", - "integrity": "sha512-RZQX3luWnlNWgdMnLMQ1hyfQraeAn9lnxWWVCHuUM4tAWEV8UDdeb7cMwmJW7eyt8kAosmjeHt3cylQMHOxGFg==", + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.68.tgz", + "integrity": "sha512-Fc5Dez23u0FoSATurT6/w1oMytiRnKWEinHivdMvXpge6nG4YvhrASrtqMk8dGJMVQpHr8QJYF45rOrx2YU2Aw==", "cpu": [ "x64" ], @@ -8729,6 +8615,10 @@ "resolved": "services/clsi", "link": true }, + "node_modules/@overleaf/clsi-cache": { + "resolved": "services/clsi-cache", + "link": true + }, "node_modules/@overleaf/clsi-perf": { "resolved": "services/clsi-perf", "link": true @@ -8884,9 +8774,9 @@ } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", - "integrity": "sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.16.tgz", + "integrity": "sha512-kLQc9xz6QIqd2oIYyXRUiAp79kGpFBm3fEM9ahfG1HI0WI5gdZ2OVHWdmZYnwODt7ISck+QuQ6sBPrtvUBML7Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8970,9 +8860,9 @@ "license": "MIT" }, "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9161,10 +9051,11 @@ "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" }, "node_modules/@react-aria/ssr": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.4.tgz", - "integrity": "sha512-4jmAigVq409qcJvQyuorsmBR4+9r3+JEC60wC+Y0MZV0HCtTmm8D9guYXlJMdx0SSkgj0hHAyFm/HvPNFofCoQ==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz", + "integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" }, @@ -9172,31 +9063,35 @@ "node": ">= 12" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@react-dnd/asap": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@react-dnd/invariant": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@react-dnd/shallowequal": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@remix-run/router": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz", - "integrity": "sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -9243,6 +9138,7 @@ "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", "dev": true, + "license": "MIT", "dependencies": { "dequal": "^2.0.3" }, @@ -9251,19 +9147,20 @@ } }, "node_modules/@restart/ui": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.6.9.tgz", - "integrity": "sha512-mUbygUsJcRurjZCt1f77gg4DpheD1D+Sc7J3JjAkysUj7t8m4EBJVOqWC9788Qtbc69cJ+HlJc6jBguKwS8Mcw==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.21.0", - "@popperjs/core": "^2.11.6", + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", "@react-aria/ssr": "^3.5.0", - "@restart/hooks": "^0.4.9", - "@types/warning": "^3.0.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", "dequal": "^2.0.3", "dom-helpers": "^5.2.0", - "uncontrollable": "^8.0.1", + "uncontrollable": "^8.0.4", "warning": "^4.0.3" }, "peerDependencies": { @@ -9271,14 +9168,17 @@ "react-dom": ">=16.14.0" } }, - "node_modules/@restart/ui/node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" } }, "node_modules/@restart/ui/node_modules/uncontrollable": { @@ -9286,18 +9186,290 @@ "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", "dev": true, + "license": "MIT", "peerDependencies": { "react": ">=16.14.0" } }, - "node_modules/@restart/ui/node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", + "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==", + "cpu": [ + "arm" + ], "dev": true, - "dependencies": { - "loose-envify": "^1.0.0" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", + "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz", + "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz", + "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz", + "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz", + "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz", + "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz", + "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz", + "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz", + "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz", + "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz", + "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz", + "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz", + "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz", + "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz", + "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz", + "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz", + "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz", + "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", + "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@sentry-internal/tracing": { "version": "7.46.0", @@ -10089,14 +10261,15 @@ } }, "node_modules/@storybook/addon-a11y": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-8.6.4.tgz", - "integrity": "sha512-B3/d2cRlnpAlE3kh+OBaly6qrWN9DEqwDyZsNeobaiXnNp11xoHZP2OWjEwXldc0pKls41jeOksXyXrILfvTng==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-8.6.12.tgz", + "integrity": "sha512-H28zHiL8uuv29XsVNf9VjNWsCeht/l66GPYHT7aom1jh+f3fS9+sutrCGEBC/T7cnRpy8ZyuHCtihUqS+RI4pg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/addon-highlight": "8.6.4", - "@storybook/test": "8.6.4", + "@storybook/addon-highlight": "8.6.12", + "@storybook/global": "^5.0.0", + "@storybook/test": "8.6.12", "axe-core": "^4.2.0" }, "funding": { @@ -10104,13 +10277,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-actions": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.4.tgz", - "integrity": "sha512-mCcyfkeb19fJX0dpQqqZCnWBwjVn0/27xcpR0mbm/KW2wTByU6bKFFujgrHsX3ONl97IcIaUnmwwUwBr1ebZXw==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.12.tgz", + "integrity": "sha512-B5kfiRvi35oJ0NIo53CGH66H471A3XTzrfaa6SxXEJsgxxSeKScG5YeXcCvLiZfvANRQ7QDsmzPUgg0o3hdMXw==", "dev": true, "license": "MIT", "dependencies": { @@ -10125,7 +10298,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-actions/node_modules/uuid": { @@ -10143,9 +10316,9 @@ } }, "node_modules/@storybook/addon-backgrounds": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.4.tgz", - "integrity": "sha512-lRYGumlYdd1RptQJvOTRMx/q2pDmg2MO5GX4la7VfI8KrUyeuC1ZOSRDEcXeTuAZWJztqmtymg6bB7cAAoxCFA==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.12.tgz", + "integrity": "sha512-lmIAma9BiiCTbJ8YfdZkXjpnAIrOUcgboLkt1f6XJ78vNEMnLNzD9gnh7Tssz1qrqvm34v9daDjIb+ggdiKp3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -10158,13 +10331,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-controls": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.4.tgz", - "integrity": "sha512-oMMP9Bj0RMfYmaitjFt6oBSjKH4titUqP+wE6PrZ3v+Om56f4buqfNKXRf80As2OrsZn0pjj95muWzVVHqIhyQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.12.tgz", + "integrity": "sha512-9VSRPJWQVb9wLp21uvpxDGNctYptyUX0gbvxIWOHMH3R2DslSoq41lsC/oQ4l4zSHVdL+nq8sCTkhBxIsjKqdQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10177,20 +10350,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-docs": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.4.tgz", - "integrity": "sha512-+kbcjvEAH0Xs+k+raAwfC0WmJilWhxBYnLLeazP3m5AkVI3sIjbzuuZ78NR0DCdRkw9BpuuXMHv5o4tIvLIUlw==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.12.tgz", + "integrity": "sha512-kEezQjAf/p3SpDzLABgg4fbT48B6dkT2LiZCKTRmCrJVtuReaAr4R9MMM6Jsph6XjbIj/SvOWf3CMeOPXOs9sg==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.6.4", - "@storybook/csf-plugin": "8.6.4", - "@storybook/react-dom-shim": "8.6.4", + "@storybook/blocks": "8.6.12", + "@storybook/csf-plugin": "8.6.12", + "@storybook/react-dom-shim": "8.6.12", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -10200,25 +10373,25 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-essentials": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.4.tgz", - "integrity": "sha512-3pF0ZDl5EICqe0eOupPQq6PxeupwkLsfTWANuuJUYTJur82kvJd3Chb7P9vqw0A0QBx6106mL6PIyjrFJJMhLg==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.12.tgz", + "integrity": "sha512-Y/7e8KFlttaNfv7q2zoHMPdX6hPXHdsuQMAjYl5NG9HOAJREu4XBy4KZpbcozRe4ApZ78rYsN/MO1EuA+bNMIA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/addon-actions": "8.6.4", - "@storybook/addon-backgrounds": "8.6.4", - "@storybook/addon-controls": "8.6.4", - "@storybook/addon-docs": "8.6.4", - "@storybook/addon-highlight": "8.6.4", - "@storybook/addon-measure": "8.6.4", - "@storybook/addon-outline": "8.6.4", - "@storybook/addon-toolbars": "8.6.4", - "@storybook/addon-viewport": "8.6.4", + "@storybook/addon-actions": "8.6.12", + "@storybook/addon-backgrounds": "8.6.12", + "@storybook/addon-controls": "8.6.12", + "@storybook/addon-docs": "8.6.12", + "@storybook/addon-highlight": "8.6.12", + "@storybook/addon-measure": "8.6.12", + "@storybook/addon-outline": "8.6.12", + "@storybook/addon-toolbars": "8.6.12", + "@storybook/addon-viewport": "8.6.12", "ts-dedent": "^2.0.0" }, "funding": { @@ -10226,13 +10399,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-highlight": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.4.tgz", - "integrity": "sha512-jFREXnSE/7VuBR8kbluN+DBVkMXEV7MGuCe8Ytb1/D2Q0ohgJe395dfVgEgSMXErOwsn//NV/NgJp6JNXH2DrA==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.12.tgz", + "integrity": "sha512-9FITVxdoycZ+eXuAZL9ElWyML/0fPPn9UgnnAkrU7zkMi+Segq/Tx7y+WWanC5zfWZrXAuG6WTOYEXeWQdm//w==", "dev": true, "license": "MIT", "dependencies": { @@ -10243,19 +10416,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-interactions": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.4.tgz", - "integrity": "sha512-MZAAZjyvmJXCvM35zEiPpXz7vK+fimovt+WZKAMayAbXy5fT+7El0c9dDyTQ2norNKNj9QU/8hiU/1zARSUELQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.12.tgz", + "integrity": "sha512-cTAJlTq6uVZBEbtwdXkXoPQ4jHOAGKQnYSezBT4pfNkdjn/FnEeaQhMBDzf14h2wr5OgBnJa6Lmd8LD9ficz4A==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.6.4", - "@storybook/test": "8.6.4", + "@storybook/instrumenter": "8.6.12", + "@storybook/test": "8.6.12", "polished": "^4.2.2", "ts-dedent": "^2.2.0" }, @@ -10264,13 +10437,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-links": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.6.4.tgz", - "integrity": "sha512-TaSIteYLJ12+dVBk7fW96ZvNIFizKs+Vo/YuNAe4xTzFJRrjLkFj9htLVi/dusMfn7lYo5DHIns08LuM+po1Dg==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.6.12.tgz", + "integrity": "sha512-AfKujFHoAxhxq4yu+6NwylltS9lf5MPs1eLLXvOlwo3l7Y/c68OdxJ7j68vLQhs9H173WVYjKyjbjFxJWf/YYg==", "dev": true, "license": "MIT", "dependencies": { @@ -10283,7 +10456,7 @@ }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.4" + "storybook": "^8.6.12" }, "peerDependenciesMeta": { "react": { @@ -10292,9 +10465,9 @@ } }, "node_modules/@storybook/addon-measure": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.4.tgz", - "integrity": "sha512-IpVL1rTy1tO8sy140eU3GdVB1QJ6J62+V6GSstcmqTLxDJQk5jFfg7hVbPEAZZ2sPFmeyceP9AMoBBo0EB355A==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.12.tgz", + "integrity": "sha512-tACmwqqOvutaQSduw8SMb62wICaT1rWaHtMN3vtWXuxgDPSdJQxLP+wdVyRYMAgpxhLyIO7YRf++Hfha9RHgFg==", "dev": true, "license": "MIT", "dependencies": { @@ -10306,13 +10479,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-outline": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.4.tgz", - "integrity": "sha512-28nAslKTy0zWMdxAZcipMDYrEp1TkXVooAsqMGY5AMXMiORi1ObjhmjTLhVt1dXp+aDg0X+M3B6PqoingmHhqQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.12.tgz", + "integrity": "sha512-1ylwm+n1s40S91No0v9T4tCjZORu3GbnjINlyjYTDLLhQHyBQd3nWR1Y1eewU4xH4cW9SnSLcMQFS/82xHqU6A==", "dev": true, "license": "MIT", "dependencies": { @@ -10324,7 +10497,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-styling-webpack": { @@ -10341,9 +10514,9 @@ } }, "node_modules/@storybook/addon-toolbars": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.4.tgz", - "integrity": "sha512-PU2lvgwCKDn93zpp5MEog103UUmSSugcxDf18xaoa9D15Qtr+YuQHd2hXbxA7+dnYL9lA7MLYsstfxE91ieM4Q==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.12.tgz", + "integrity": "sha512-HEcSzo1DyFtIu5/ikVOmh5h85C1IvK9iFKSzBR6ice33zBOaehVJK+Z5f487MOXxPsZ63uvWUytwPyViGInj+g==", "dev": true, "license": "MIT", "funding": { @@ -10351,13 +10524,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-viewport": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.4.tgz", - "integrity": "sha512-O5Ij+SRVg6grY6JOL5lOpsFyopZxuZEl2GHfh2SUf9hfowNS0QAgFpJupqXkwZzRSrlf9uKrLkjB6ulLgN2gOQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.12.tgz", + "integrity": "sha512-EXK2LArAnABsPP0leJKy78L/lbMWow+EIJfytEP5fHaW4EhMR6h7Hzaqzre6U0IMMr/jVFa1ci+m0PJ0eQc2bw==", "dev": true, "license": "MIT", "dependencies": { @@ -10368,13 +10541,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/addon-webpack5-compiler-babel": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-3.0.5.tgz", - "integrity": "sha512-9dlc5PrehEFUHqkgj8x+aKtOY9XH9Zk6WBbtpgY/JCQ7waJ2VvhyDnrgJeXfek+WYlSkJElnta6SlqP+XRG0PQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-3.0.6.tgz", + "integrity": "sha512-J4uVxEfkd2iAxPxcT90iebt5wuLSd0EYuMJa94t1jVUGlvZZAvnmqXAqscRITNU37nOr0c9yZ2YVS/sFOZyOVw==", "dev": true, "license": "MIT", "dependencies": { @@ -10386,9 +10559,9 @@ } }, "node_modules/@storybook/blocks": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.4.tgz", - "integrity": "sha512-+oPXwT3KzJzsdkQuGEzBqOKTIFlb6qmlCWWbDwAnP0SEqYHoTVRTAIa44icFP0EZeIe+ypFVAm1E7kWTLmw1hQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.12.tgz", + "integrity": "sha512-DohlTq6HM1jDbHYiXL4ZvZ00VkhpUp5uftzj/CZDLY1fYHRjqtaTwWm2/OpceivMA8zDitLcq5atEZN+f+siTg==", "dev": true, "license": "MIT", "dependencies": { @@ -10402,7 +10575,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^8.6.4" + "storybook": "^8.6.12" }, "peerDependenciesMeta": { "react": { @@ -10414,13 +10587,13 @@ } }, "node_modules/@storybook/builder-webpack5": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.6.4.tgz", - "integrity": "sha512-6fhjt3uiBZeapRbF477bkJ+ln+yA8vOz0qR86XTq79VrYY5AbBL6F8swVMk9LG1t49vYPR/UuPjYBxsUNKK8MQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-8.6.12.tgz", + "integrity": "sha512-Z7RsQ/1+HbxdbM69JrEFcTL+pnVKUTMmeURMn5/eOvYTGjBtM18vbQTj0LjCUDIjC+v9U+uX8ZJEUVxFbGcxBw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core-webpack": "8.6.4", + "@storybook/core-webpack": "8.6.12", "@types/semver": "^7.3.4", "browser-assert": "^1.2.1", "case-sensitive-paths-webpack-plugin": "^2.4.0", @@ -10450,7 +10623,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" }, "peerDependenciesMeta": { "typescript": { @@ -10503,9 +10676,9 @@ "license": "MIT" }, "node_modules/@storybook/builder-webpack5/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10579,18 +10752,18 @@ } }, "node_modules/@storybook/cli": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-8.6.4.tgz", - "integrity": "sha512-iVw4B2Pe4/ERDkDeaXtXamFXatNgvtiA6G9p3wUpVSlxjgKW/JbjSwKAMTCsgDIj4dCMm8i0fzmiYXeg5Yprng==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-8.6.12.tgz", + "integrity": "sha512-USOPHgQsckHguxqYItm7+i9lohjiPsCrNKBMl40XPU3dc1LqENBOF1JtJnOLTGegjUxRox7oaqrM10b9rkafWA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/types": "^7.24.0", - "@storybook/codemod": "8.6.4", + "@storybook/codemod": "8.6.12", "@types/semver": "^7.3.4", "commander": "^12.1.0", - "create-storybook": "8.6.4", + "create-storybook": "8.6.12", "cross-spawn": "^7.0.3", "envinfo": "^7.7.3", "fd-package-json": "^1.2.0", @@ -10603,7 +10776,7 @@ "p-limit": "^6.2.0", "prompts": "^2.4.0", "semver": "^7.3.7", - "storybook": "8.6.4", + "storybook": "8.6.12", "tiny-invariant": "^1.3.1", "ts-dedent": "^2.0.0" }, @@ -10904,9 +11077,9 @@ "license": "ISC" }, "node_modules/@storybook/cli/node_modules/yocto-queue": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.0.tgz", - "integrity": "sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", "dev": true, "license": "MIT", "engines": { @@ -10917,16 +11090,16 @@ } }, "node_modules/@storybook/codemod": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.6.4.tgz", - "integrity": "sha512-HVB7py6vKB9OMzQ02aAhcqmyT/IDlYrT1960HO6LWRhcpztnBlOHAAlhM91DN8yqN0K47B+GsaN5eDzCT8ggBw==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.6.12.tgz", + "integrity": "sha512-RFIGUI+dkFqrcuOyxNY97WuW7dskmuGL9yGALQsNmyGC490VUJ3scLOAgBChWBjsDfnN8uSb0Kvt/fB4OOyCmw==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/preset-env": "^7.24.4", "@babel/types": "^7.24.0", - "@storybook/core": "8.6.4", + "@storybook/core": "8.6.12", "@types/cross-spawn": "^6.0.2", "cross-spawn": "^7.0.3", "es-toolkit": "^1.22.0", @@ -11097,9 +11270,9 @@ } }, "node_modules/@storybook/components": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.4.tgz", - "integrity": "sha512-91VEVFWOgHkEFoNFMk6gs1AuOE9Yp7N283BXQOW+AgP+atpzED6t/fIBPGqJ2ewAuzLJ+cFOrasSzoNwVfg3Jg==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.12.tgz", + "integrity": "sha512-FiaE8xvCdvKC2arYusgtlDNZ77b8ysr8njAYQZwwaIHjy27TbR2tEpLDCmUwSbANNmivtc/xGEiDDwcNppMWlQ==", "dev": true, "license": "MIT", "funding": { @@ -11111,13 +11284,13 @@ } }, "node_modules/@storybook/core": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.4.tgz", - "integrity": "sha512-glDbjEBi3wokw1T+KQtl93irHO9N0LCwgylWfWVXYDdQjUJ7pGRQGnw73gPX7Ds9tg3myXFC83GjmY94UYSMbA==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.12.tgz", + "integrity": "sha512-t+ZuDzAlsXKa6tLxNZT81gEAt4GNwsKP/Id2wluhmUWD/lwYW0uum1JiPUuanw8xD6TdakCW/7ULZc7aQUBLCQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/theming": "8.6.4", + "@storybook/theming": "8.6.12", "better-opn": "^3.0.2", "browser-assert": "^1.2.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", @@ -11143,9 +11316,9 @@ } }, "node_modules/@storybook/core-webpack": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.6.4.tgz", - "integrity": "sha512-/E+NDs4Ls2KQhQJyEbqyddvcevPGCNbBIRoR691gq2lnZV7lYFfhpGfYlXL1uSoA3WUWmql/gBsa2/O3vB+HKg==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-8.6.12.tgz", + "integrity": "sha512-TiE+KOm0hxb/p0JxeGHKxqTNX+xnTOFsBh6uloCSuvodutJ5pR/XpxKVxwo1gtSc0Uq3qpgbMhW6qYlYQetnKA==", "dev": true, "license": "MIT", "dependencies": { @@ -11156,7 +11329,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/core/node_modules/semver": { @@ -11173,9 +11346,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.4.tgz", - "integrity": "sha512-7UpEp4PFTy1iKjZiRaYMG7zvnpLIRPyD0+lUJUlLYG4UIemV3onvnIi1Je1tSZ4hfTup+ulom7JLztVSHZGRMg==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.12.tgz", + "integrity": "sha512-6s8CnP1aoKPb3XtC0jRLUp8M5vTA8RhGAwQDKUsFpCC7g89JR9CaKs9FY2ZSzsNbjR15uASi7b3K8BzeYumYQg==", "dev": true, "license": "MIT", "dependencies": { @@ -11186,7 +11359,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/global": { @@ -11197,9 +11370,9 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.3.2.tgz", - "integrity": "sha512-t3xcbCKkPvqyef8urBM0j/nP6sKtnlRkVgC+8JTbTAZQjaTmOjes3byEgzs89p4B/K6cJsg9wLW2k3SknLtYJw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.4.0.tgz", + "integrity": "sha512-Td73IeJxOyalzvjQL+JXx72jlIYHgs+REaHiREOqfpo3A2AYYG71AUbcv+lg7mEDIweKVCxsMQ0UKo634c8XeA==", "dev": true, "license": "MIT", "engines": { @@ -11211,9 +11384,9 @@ } }, "node_modules/@storybook/instrumenter": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.4.tgz", - "integrity": "sha512-8OtIWLhayTUdqJEeXiPm6l3LTdSkWgQzzV2l2HIe4Adedeot+Rkwu6XHmyRDpnb0+Ish6zmMDqtJBxC2PQsy6Q==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.12.tgz", + "integrity": "sha512-VK5fYAF8jMwWP/u3YsmSwKGh+FeSY8WZn78flzRUwirp2Eg1WWjsqPRubAk7yTpcqcC/km9YMF3KbqfzRv2s/A==", "dev": true, "license": "MIT", "dependencies": { @@ -11225,13 +11398,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/manager-api": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.4.tgz", - "integrity": "sha512-w/Nn/VznfbIg2oezDfzZNwSTDY5kBZbzxVBHLCnIcyu2AKt2Yto3pfGi60SikFcTrsClaAKT7D92kMQ9qdQNQQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.12.tgz", + "integrity": "sha512-O0SpISeJLNTQvhSBOsWzzkCgs8vCjOq1578rwqHlC6jWWm4QmtfdyXqnv7rR1Hk08kQ+Dzqh0uhwHx0nfwy4nQ==", "dev": true, "license": "MIT", "funding": { @@ -11243,24 +11416,28 @@ } }, "node_modules/@storybook/node-logger": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-8.0.4.tgz", - "integrity": "sha512-cALLHuX53vLQsoJamGRlquh2pfhPq9copXou2JTmFT6mrCcipo77SzhBDfeeuhaGv6vUWPfmGjPBEHXWGPe4+g==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-8.6.12.tgz", + "integrity": "sha512-Jk7mQWsu60BptBwYJAd69kMmsEqBAbGDuA/fqban+8vfNiSKgR3PRkhis0DsGEk53bpAEfbkCcyvYRCrrq4M3Q==", "dev": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "node_modules/@storybook/preset-react-webpack": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-8.6.4.tgz", - "integrity": "sha512-rFd1NvSE2ZP5ZFEqH7wdXXlvnyNChSMp+w4FyGSCgFQOwQKZhhWPPyloi3gGSWztFV9qpzC/ri7TTvG6ptqPPw==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-8.6.12.tgz", + "integrity": "sha512-aCCHjR/jsVPVThRH7nK70wS0Od44M6hqkkakg3xr7LETZZGj99heen6t4VHvz8gcQYT9l6R/oZwCl7f/PQ3ZBQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core-webpack": "8.6.4", - "@storybook/react": "8.6.4", + "@storybook/core-webpack": "8.6.12", + "@storybook/react": "8.6.12", "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", "@types/semver": "^7.3.4", "find-up": "^5.0.0", @@ -11281,7 +11458,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.4" + "storybook": "^8.6.12" }, "peerDependenciesMeta": { "typescript": { @@ -11318,9 +11495,9 @@ } }, "node_modules/@storybook/preview-api": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.4.tgz", - "integrity": "sha512-5HBfxggzxGz0dg2c61NpPiQJav7UAmzsQlzmI5SzWOS6lkaylcDG8giwKzASVCXVWBxNji9qIDFM++UH090aDg==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.12.tgz", + "integrity": "sha512-84FE3Hrs0AYKHqpDZOwx1S/ffOfxBdL65lhCoeI8GoWwCkzwa9zEP3kvXBo/BnEDO7nAfxvMhjASTZXbKRJh5Q==", "dev": true, "license": "MIT", "funding": { @@ -11332,18 +11509,18 @@ } }, "node_modules/@storybook/react": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.4.tgz", - "integrity": "sha512-pfv4hMhu3AScOh0l86uIzmXLSQ0XA/e0reIVwQcxKht6miaKArhx9GkS4mMp6SO23ZoV5G/nfLgUaMVPVE0ZPg==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.12.tgz", + "integrity": "sha512-NzxlHLA5DkDgZM/dMwTYinuzRs6rsUPmlqP+NIv6YaciQ4NGnTYyOC7R/SqI6HHFm8ZZ5eMYvpfiFmhZ9rU+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/components": "8.6.4", + "@storybook/components": "8.6.12", "@storybook/global": "^5.0.0", - "@storybook/manager-api": "8.6.4", - "@storybook/preview-api": "8.6.4", - "@storybook/react-dom-shim": "8.6.4", - "@storybook/theming": "8.6.4" + "@storybook/manager-api": "8.6.12", + "@storybook/preview-api": "8.6.12", + "@storybook/react-dom-shim": "8.6.12", + "@storybook/theming": "8.6.12" }, "engines": { "node": ">=18.0.0" @@ -11353,10 +11530,10 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "@storybook/test": "8.6.4", + "@storybook/test": "8.6.12", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.4", + "storybook": "^8.6.12", "typescript": ">= 4.2.x" }, "peerDependenciesMeta": { @@ -11423,9 +11600,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.4.tgz", - "integrity": "sha512-kTGJ3aFdmfCFzYaDFGmZWfTXr9xhbUaf0tJ6+nEjc4tME6mFwMI+tTUT6U/J6mJhZuc2DjvIRA7bM0x77dIDqw==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.12.tgz", + "integrity": "sha512-51QvoimkBzYs8s3rCYnY5h0cFqLz/Mh0vRcughwYaXckWzDBV8l67WBO5Xf5nBsukCbWyqBVPpEQLww8s7mrLA==", "dev": true, "license": "MIT", "funding": { @@ -11435,19 +11612,19 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.4" + "storybook": "^8.6.12" } }, "node_modules/@storybook/react-webpack5": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-8.6.4.tgz", - "integrity": "sha512-kH439Atpp94+hWF/xftOJ4ZCy7bnNWuLSni7sWvOGkYZpzzzkLXfACanvK6ZY9wUxAh0bbdGfbc3McMvIWfYlw==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-8.6.12.tgz", + "integrity": "sha512-wZOjPQ00gu85iQoKgwz5uvM3+bhXrQDVR0ppVAj7vVy6cvLEsJXmqNLHbXPCZuKPmvwzYr1QkslMLCIkF8OGdA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-webpack5": "8.6.4", - "@storybook/preset-react-webpack": "8.6.4", - "@storybook/react": "8.6.4" + "@storybook/builder-webpack5": "8.6.12", + "@storybook/preset-react-webpack": "8.6.12", + "@storybook/react": "8.6.12" }, "engines": { "node": ">=18.0.0" @@ -11459,7 +11636,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.4", + "storybook": "^8.6.12", "typescript": ">= 4.2.x" }, "peerDependenciesMeta": { @@ -11469,14 +11646,14 @@ } }, "node_modules/@storybook/test": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.4.tgz", - "integrity": "sha512-JPjfbaMMuCBT47pg3/MDD9vYFF5OGPAOWEB9nJWJ9IjYAb2Nd8OYJQIDoYJQNT+aLkTVLtvzGnVNwdxpouAJcQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.12.tgz", + "integrity": "sha512-0BK1Eg+VD0lNMB1BtxqHE3tP9FdkUmohtvWG7cq6lWvMrbCmAmh3VWai3RMCCDOukPFpjabOr8BBRLVvhNpv2w==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.6.4", + "@storybook/instrumenter": "8.6.12", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", @@ -11488,96 +11665,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.4" - } - }, - "node_modules/@storybook/test/node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@storybook/test/node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/test/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@storybook/test/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@storybook/test/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@storybook/test/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "storybook": "^8.6.12" } }, "node_modules/@storybook/theming": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.4.tgz", - "integrity": "sha512-g9Ns4uenC9oAWETaJ/tEKEIPMdS+CqjNWZz5Wbw1bLNhXwADZgKrVqawzZi64+bYYtQ+i8VCTjPoFa6s2eHiDQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.12.tgz", + "integrity": "sha512-6VjZg8HJ2Op7+KV7ihJpYrDnFtd9D1jrQnUS8LckcpuBXrIEbaut5+34ObY8ssQnSqkk2GwIZBBBQYQBCVvkOw==", "dev": true, "license": "MIT", "funding": { @@ -11612,12 +11706,13 @@ } }, "node_modules/@swc/helpers": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.11.tgz", - "integrity": "sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==", + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@testing-library/cypress": { @@ -11656,12 +11751,6 @@ "node": ">=14" } }, - "node_modules/@testing-library/cypress/node_modules/@types/aria-query": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.2.tgz", - "integrity": "sha512-PHKZuMN+K5qgKIWhBodXzQslTo5P+K/6LqeKXS6O/4liIDdZqaX5RXrCK++LAw+y/nptN48YmUMFiQHRSWYwtQ==", - "dev": true - }, "node_modules/@testing-library/cypress/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -11706,22 +11795,23 @@ } }, "node_modules/@testing-library/dom": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz", - "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", - "@types/aria-query": "^4.2.0", - "aria-query": "^5.0.0", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.4.4", + "lz-string": "^1.5.0", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { @@ -11739,6 +11829,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/@testing-library/dom/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11839,69 +11939,33 @@ } }, "node_modules/@testing-library/react": { - "version": "12.1.5", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", - "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.0.0", - "@types/react-dom": "<18.0.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=12" + "node": ">=18" }, "peerDependencies": { - "react": "<18.0.0", - "react-dom": "<18.0.0" - } - }, - "node_modules/@testing-library/react-hooks": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", - "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "react-error-boundary": "^3.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0", - "react": "^16.9.0 || ^17.0.0", - "react-dom": "^16.9.0 || ^17.0.0", - "react-test-renderer": "^16.9.0 || ^17.0.0" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { "optional": true }, - "react-dom": { - "optional": true - }, - "react-test-renderer": { + "@types/react-dom": { "optional": true } } }, - "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", - "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - }, - "peerDependencies": { - "react": ">=16.13.1" - } - }, "node_modules/@testing-library/user-event": { "version": "14.5.2", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", @@ -11924,10 +11988,11 @@ } }, "node_modules/@transloadit/prettier-bytes": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz", - "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==", - "dev": true + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", + "integrity": "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==", + "dev": true, + "license": "MIT" }, "node_modules/@trysound/sax": { "version": "0.2.0", @@ -11957,10 +12022,11 @@ } }, "node_modules/@types/aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", - "dev": true + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/aws-lambda": { "version": "8.10.119", @@ -11982,9 +12048,9 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -12003,9 +12069,9 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", "dev": true, "license": "MIT", "dependencies": { @@ -12026,6 +12092,7 @@ "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -12094,6 +12161,7 @@ "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", "dev": true, + "license": "MIT", "dependencies": { "@types/express-serve-static-core": "*", "@types/node": "*" @@ -12112,7 +12180,8 @@ "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" }, "node_modules/@types/cookiejar": { "version": "2.1.2", @@ -12191,9 +12260,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "dev": true, "license": "MIT" }, @@ -12234,6 +12303,13 @@ "@types/node": "*" } }, + "node_modules/@types/glob-to-regexp": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz", + "integrity": "sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hapi__catbox": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/@types/hapi__catbox/-/hapi__catbox-10.2.4.tgz", @@ -12271,9 +12347,10 @@ } }, "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", - "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "license": "MIT", "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -12564,9 +12641,10 @@ } }, "node_modules/@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" }, "node_modules/@types/qs": { "version": "6.9.7", @@ -12579,75 +12657,73 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "node_modules/@types/react": { - "version": "17.0.40", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.40.tgz", - "integrity": "sha512-UrXhD/JyLH+W70nNSufXqMZNuUD2cXHu6UjCllC6pmOQgBX4SGXOH8fjRka0O0Ee0HrFxapDD8Bwn81Kmiz6jQ==", + "version": "18.3.20", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", + "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + "license": "MIT", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-bootstrap": { - "version": "0.32.36", - "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.36.tgz", - "integrity": "sha512-xldfs2zixagAFEafy/XzRvZH1NtjRnLfbgL0cZ2a0Eykz+iILE/Xa46tnUFcLln6ZBq1Qp9uArhIbkkuhBU30g==", + "version": "0.32.37", + "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.37.tgz", + "integrity": "sha512-CVHj++uxsj1pRnM3RQ/NAXcWj+JwJZ3MqQ28sS1OQUD1sI2gRlbeAjRT+ak2nuwL+CY+gtnIsMaIDq0RNfN0PA==", "dev": true, + "license": "MIT", "dependencies": { "@types/react": "*" } }, "node_modules/@types/react-color": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.6.tgz", - "integrity": "sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==", + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz", + "integrity": "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/react": "*", "@types/reactcss": "*" - } - }, - "node_modules/@types/react-dom": { - "version": "17.0.13", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.13.tgz", - "integrity": "sha512-wEP+B8hzvy6ORDv1QBhcQia4j6ea4SFIBttHYpXKPFZRviBvknq0FRh3VrIxeXUmsPkwuXVZrVGG7KUVONmXCQ==", - "dev": true, - "dependencies": { + }, + "peerDependencies": { "@types/react": "*" } }, - "node_modules/@types/react-google-recaptcha": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.5.tgz", - "integrity": "sha512-iWTjmVttlNgp0teyh7eBXqNOQzVq2RWNiFROWjraOptRnb1OcHJehQnji0sjqIRAk9K0z8stjyhU+OLpPb0N6w==", + "node_modules/@types/react-dom": { + "version": "18.3.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.6.tgz", + "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "dev": true, + "license": "MIT", "dependencies": { "@types/react": "*" } }, "node_modules/@types/react-linkify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz", - "integrity": "sha512-2NKXPQGaHNfh/dCqkVC55k1tAhQyNoNZa31J50nIneMVwHqUI00FAP+Lyp8e0BarPf84kn4GRVAhtWX9XJBzSQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.4.tgz", + "integrity": "sha512-NOMw4X3kjvjY0lT5kXQdxZCXpPNi2hOuuqG+Kz+5EOQpi9rDUJJDitdE1j2JRNmrTnNIjrLnYG0HKyuOWN/uKA==", "dev": true, + "license": "MIT", "dependencies": { "@types/react": "*" } }, - "node_modules/@types/react-overlays": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/react-overlays/-/react-overlays-1.1.3.tgz", - "integrity": "sha512-oOq5NWbyfNz2w2sKvjkHdvGQSMA+VDVfI5UOfGPR0wkik2welad1RDVnVgH15jKf58jrZNBa1Ee4SVBgCGFxCg==", - "dev": true, - "dependencies": { - "@types/react": "*", - "@types/react-transition-group": "*" - } - }, "node_modules/@types/react-redux": { - "version": "7.1.33", - "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", - "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "version": "7.1.34", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", + "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", + "license": "MIT", "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", "@types/react": "*", @@ -12656,20 +12732,22 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", - "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "dev": true, - "dependencies": { + "license": "MIT", + "peerDependencies": { "@types/react": "*" } }, "node_modules/@types/reactcss": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz", - "integrity": "sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz", + "integrity": "sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==", "dev": true, - "dependencies": { + "license": "MIT", + "peerDependencies": { "@types/react": "*" } }, @@ -12725,11 +12803,6 @@ "@types/node": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" - }, "node_modules/@types/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", @@ -12749,6 +12822,7 @@ "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*" } @@ -12819,6 +12893,7 @@ "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -12868,7 +12943,8 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", @@ -12893,10 +12969,11 @@ } }, "node_modules/@types/ws": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", - "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -13849,6 +13926,155 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, + "node_modules/@uppy/companion-client": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-3.8.2.tgz", + "integrity": "sha512-WLjZ0Y6Fe7lzwU1YPvvQ/YqooejcgIZkT2TC39xr+QQ7Y1FwJECsyUdlKwgi1ee8TNpjoCrj3Q1Hjel/+p0VhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@uppy/utils": "^5.9.0", + "namespace-emitter": "^2.0.1", + "p-retry": "^6.1.0" + }, + "peerDependencies": { + "@uppy/core": "^3.13.1" + } + }, + "node_modules/@uppy/core": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@uppy/core/-/core-3.13.1.tgz", + "integrity": "sha512-iQGAUO4ziQRpfv7kix6tO6JOWqjI0K4vt8AynvHWzDPZxYSba3zd6RojGNPsYWSR7Xv+dRXYx+GU8oTiK1FRUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@transloadit/prettier-bytes": "^0.3.4", + "@uppy/store-default": "^3.2.2", + "@uppy/utils": "^5.9.0", + "lodash": "^4.17.21", + "mime-match": "^1.0.2", + "namespace-emitter": "^2.0.1", + "nanoid": "^4.0.0", + "preact": "^10.5.13" + } + }, + "node_modules/@uppy/informer": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@uppy/informer/-/informer-3.1.0.tgz", + "integrity": "sha512-vmpTLqzSLmZSuIVDZV0o19yXVqyTh5/uCbKUEiyfBhR726kQiuYQLP/ZHaKcvW3c1ESQGbNg53iNHbFBqF681w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@uppy/utils": "^5.7.4", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^3.9.3" + } + }, + "node_modules/@uppy/provider-views": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-3.13.0.tgz", + "integrity": "sha512-Z2oI88A+GC2zIPk8beoeFN/miHKkhtF58mYjvb5miGCMMZM7p7LRj98sgb5OOdKsGrfeiuTavtgL424BvcVd8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@uppy/utils": "^5.9.0", + "classnames": "^2.2.6", + "nanoid": "^4.0.0", + "p-queue": "^7.3.4", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^3.13.0" + } + }, + "node_modules/@uppy/provider-views/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@uppy/provider-views/node_modules/p-queue": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.4.1.tgz", + "integrity": "sha512-vRpMXmIkYF2/1hLBKisKeVYJZ8S2tZ0zEAmIJgdVKP2nq0nh4qCdf8bgw+ZgKrkh71AOCaqzwbJJk1WtdcF3VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^5.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@uppy/provider-views/node_modules/p-timeout": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz", + "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@uppy/status-bar": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@uppy/status-bar/-/status-bar-3.3.3.tgz", + "integrity": "sha512-TCcnBjTDbq/AmnGOcWbCpQNsv05Z6Y36zdmTCt/xNe2/gTVAYAzGRoGOrkeb6jf/E4AAi25VyOolSqL2ibB8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@transloadit/prettier-bytes": "^0.3.4", + "@uppy/utils": "^5.9.0", + "classnames": "^2.2.6", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^3.11.2" + } + }, + "node_modules/@uppy/store-default": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-3.2.2.tgz", + "integrity": "sha512-OiSgT++Jj4nLK0N9WTeod3UNjCH81OXE5BcMJCd9oWzl2d0xPNq2T/E9Y6O72XVd+6Y7+tf5vZlPElutfMB3KQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@uppy/thumbnail-generator": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-3.1.0.tgz", + "integrity": "sha512-tDKK/cukC0CrM0F/OlHFmvpGGUq+Db4YfakhIGPKtT7ZO8aWOiIu5JIvaYUnKRxGq3RGsk4zhkxYXuoxVzzsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@uppy/utils": "^5.7.5", + "exifr": "^7.0.0" + }, + "peerDependencies": { + "@uppy/core": "^3.10.0" + } + }, + "node_modules/@uppy/utils": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-5.9.0.tgz", + "integrity": "sha512-9Ubddd3orCOLYjf0KobwgJ+aTrABSxk9t4X/QdM4qJHVZuMIftkaMplrViRUO+kvIBCXEZDIP2AmS060siDNGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "preact": "^10.5.13" + } + }, "node_modules/@vitest/expect": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", @@ -13971,6 +14197,103 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz", + "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.2", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz", + "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.2", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@vitest/spy": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", @@ -15498,6 +15821,12 @@ "deep-equal": "^2.0.5" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, "node_modules/babel-core": { "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", @@ -15785,13 +16114,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz", - "integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.1", - "core-js-compat": "^3.36.1" + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -15860,6 +16190,70 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz", + "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.0.0", + "bare-path": "^3.0.0", + "bare-stream": "^2.0.0" + }, + "engines": { + "bare": ">=1.7.0" + } + }, + "node_modules/bare-os": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.0.tgz", + "integrity": "sha512-BUrFS5TqSBdA0LwHop4OjPJwisqxGy6JsWVqV6qaFoe965qqtaKfDzHY5T2YA1gUL0ZeeQeA+4BBc1FJTcHiPw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, "node_modules/base": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", @@ -15979,8 +16373,9 @@ "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=", - "dev": true + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" }, "node_modules/bcrypt": { "version": "5.0.1", @@ -16226,10 +16621,11 @@ } }, "node_modules/bonjour-service": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", - "integrity": "sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" @@ -16315,9 +16711,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", - "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "dev": true, "funding": [ { @@ -16333,11 +16729,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001663", - "electron-to-chromium": "^1.5.28", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -16666,6 +17063,16 @@ "node": ">=10" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -16800,9 +17207,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001667", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz", - "integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==", + "version": "1.0.30001714", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001714.tgz", + "integrity": "sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==", "dev": true, "funding": [ { @@ -16817,7 +17224,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/canvas": { "version": "2.11.2", @@ -17565,6 +17973,13 @@ "node": ">= 0.8" } }, + "node_modules/combobo": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/combobo/-/combobo-2.0.4.tgz", + "integrity": "sha512-FRaTwjtUKNAyBglThL+52VDYD7Jvodb1E1nimBwybqu5ckooZn24e65hYiXSofMp/9dsPEkoAc6v5DSaQ0ENZg==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -17778,6 +18193,7 @@ "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8" } @@ -17791,9 +18207,9 @@ } }, "node_modules/consola": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", - "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "dev": true, "license": "MIT", "engines": { @@ -18123,9 +18539,9 @@ } }, "node_modules/core-js": { - "version": "3.38.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz", - "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==", + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", + "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -18135,12 +18551,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.38.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", - "integrity": "sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==", + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", + "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", "dev": true, + "license": "MIT", "dependencies": { - "browserslist": "^4.23.3" + "browserslist": "^4.24.4" }, "funding": { "type": "opencollective", @@ -18203,9 +18620,9 @@ } }, "node_modules/create-storybook": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/create-storybook/-/create-storybook-8.6.4.tgz", - "integrity": "sha512-YwxtA+CtGHWYvQrFh1dat3Q/kXWHekok0MAqaorD9/Mf/cpybA8afHDsdq2PDq0LXg0Od3QI4Ha04+eaB6F8gA==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/create-storybook/-/create-storybook-8.6.12.tgz", + "integrity": "sha512-2Yb2LuWNvOEATWjj/pDAfkGhGiTLdV1nqJZLjB6F08ItcyBSeGuFybimrac72zFq4u0GI8LkjzEg/J3B1NI4tA==", "dev": true, "license": "MIT", "dependencies": { @@ -18476,7 +18893,8 @@ "node_modules/css-mediaquery": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", - "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", + "license": "BSD" }, "node_modules/css-minimizer-webpack-plugin": { "version": "5.0.1", @@ -18814,9 +19232,10 @@ "dev": true }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" }, "node_modules/csurf": { "version": "1.11.0", @@ -19672,7 +20091,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/devlop": { "version": "1.1.0", @@ -19738,21 +20158,12 @@ "node": ">=8" } }, - "node_modules/diskusage": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.1.3.tgz", - "integrity": "sha512-EAyaxl8hy4Ph07kzlzGTfpbZMNAAAHXSZtNEMwdlnSd1noHzvA6HsgKt4fEMSvaEXQYLSphe5rPMxN4WOj0hcQ==", - "hasInstallScript": true, - "dependencies": { - "es6-promise": "^4.2.5", - "nan": "^2.14.0" - } - }, "node_modules/dnd-core": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", "dev": true, + "license": "MIT", "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", @@ -19760,10 +20171,11 @@ } }, "node_modules/dns-packet": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.0.tgz", - "integrity": "sha512-rza3UH1LwdHh9qyPXp8lkwpjSNk/AMD3dPytUoRoqnypDUhY0xvbdmVhWOfxO68frEfV9BU8V12Ez7ZsHGZpCQ==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", "dev": true, + "license": "MIT", "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" }, @@ -19803,12 +20215,14 @@ } }, "node_modules/dom-helpers": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", - "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.1.2" + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" } }, "node_modules/dom-serializer": { @@ -19931,21 +20345,36 @@ "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, "node_modules/downshift": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz", - "integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.0.9.tgz", + "integrity": "sha512-ygOT8blgiz5liDuEFAIaPeU4dDEa+w9p6PHVUisPIjrkF5wfR59a52HpGWAVVMoWnoFO8po2mZSScKZueihS7g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.14.8", - "compute-scroll-into-view": "^1.0.17", - "prop-types": "^15.7.2", - "react-is": "^17.0.2", - "tslib": "^2.3.0" + "@babel/runtime": "^7.24.5", + "compute-scroll-into-view": "^3.1.0", + "prop-types": "^15.8.1", + "react-is": "18.2.0", + "tslib": "^2.6.2" }, "peerDependencies": { "react": ">=16.12.0" } }, + "node_modules/downshift/node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/downshift/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true, + "license": "MIT" + }, "node_modules/dropbox": { "version": "10.34.0", "resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz", @@ -20113,10 +20542,11 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.34", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.34.tgz", - "integrity": "sha512-/TZAiChbAflBNjCg+VvstbcwAtIL/VdMFO3NgRFIzBjpvPzWOTIbbO8kNb6RwU4bt9TP7K+3KqBKw/lOU+Y+GA==", - "dev": true + "version": "1.5.137", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz", + "integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==", + "dev": true, + "license": "ISC" }, "node_modules/email-addresses": { "version": "5.0.0", @@ -20293,6 +20723,7 @@ "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "dev": true, + "license": "MIT", "dependencies": { "stackframe": "^1.3.4" } @@ -20397,9 +20828,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -20455,9 +20886,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.32.0.tgz", - "integrity": "sha512-ZfSfHP1l6ubgW/B/FRtqb9bYdMvI6jizbOSfbwwJNcOQ1QE6TFsC3jpQkZ900uUPSR3t3SU5Ds7UWKnYz+uP8Q==", + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.35.0.tgz", + "integrity": "sha512-kVHyrRoC0eLc1hWJ6npG8nNFtOG+nWfcMI+XE0RaFO0gxd6Ions8r0O/U64QyZgY7IeidUnS5oZlRZYUgMGCAg==", "dev": true, "license": "MIT", "workspaces": [ @@ -20517,9 +20948,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -20530,31 +20961,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@esbuild/aix-ppc64": "0.25.3", + "@esbuild/android-arm": "0.25.3", + "@esbuild/android-arm64": "0.25.3", + "@esbuild/android-x64": "0.25.3", + "@esbuild/darwin-arm64": "0.25.3", + "@esbuild/darwin-x64": "0.25.3", + "@esbuild/freebsd-arm64": "0.25.3", + "@esbuild/freebsd-x64": "0.25.3", + "@esbuild/linux-arm": "0.25.3", + "@esbuild/linux-arm64": "0.25.3", + "@esbuild/linux-ia32": "0.25.3", + "@esbuild/linux-loong64": "0.25.3", + "@esbuild/linux-mips64el": "0.25.3", + "@esbuild/linux-ppc64": "0.25.3", + "@esbuild/linux-riscv64": "0.25.3", + "@esbuild/linux-s390x": "0.25.3", + "@esbuild/linux-x64": "0.25.3", + "@esbuild/netbsd-arm64": "0.25.3", + "@esbuild/netbsd-x64": "0.25.3", + "@esbuild/openbsd-arm64": "0.25.3", + "@esbuild/openbsd-x64": "0.25.3", + "@esbuild/sunos-x64": "0.25.3", + "@esbuild/win32-arm64": "0.25.3", + "@esbuild/win32-ia32": "0.25.3", + "@esbuild/win32-x64": "0.25.3" } }, "node_modules/esbuild-register": { @@ -20589,9 +21020,10 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -22029,6 +22461,13 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "dev": true, + "license": "MIT" + }, "node_modules/expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -22108,35 +22547,14 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/expect": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-1.20.2.tgz", - "integrity": "sha512-vUOB6rNLhhRgchrNzJZH72FXDgiHmmEqX07Nlb1363HyZm/GFzkNMq0X0eIygMtdc4f2okltziddtVM4D5q0Jw==", + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", "dev": true, - "dependencies": { - "define-properties": "~1.1.2", - "has": "^1.0.1", - "is-equal": "^1.5.1", - "is-regex": "^1.0.3", - "object-inspect": "^1.1.0", - "object-keys": "^1.0.9", - "tmatch": "^2.0.1" - } - }, - "node_modules/expect/node_modules/define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", - "dev": true, - "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, + "license": "Apache-2.0", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12.0.0" } }, "node_modules/express": { @@ -22569,8 +22987,7 @@ "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -22679,6 +23096,7 @@ "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", "dev": true, + "license": "Apache-2.0", "dependencies": { "websocket-driver": ">=0.5.1" }, @@ -22740,45 +23158,6 @@ "node": ">=4.0.0" } }, - "node_modules/fetch-mock": { - "version": "9.11.0", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", - "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", - "dev": true, - "dependencies": { - "@babel/core": "^7.0.0", - "@babel/runtime": "^7.0.0", - "core-js": "^3.0.0", - "debug": "^4.1.1", - "glob-to-regexp": "^0.4.0", - "is-subset": "^0.1.1", - "lodash.isequal": "^4.5.0", - "path-to-regexp": "^2.2.1", - "querystring": "^0.2.0", - "whatwg-url": "^6.5.0" - }, - "engines": { - "node": ">=4.0.0" - }, - "funding": { - "type": "charity", - "url": "https://www.justgiving.com/refugee-support-europe" - }, - "peerDependencies": { - "node-fetch": "*" - }, - "peerDependenciesMeta": { - "node-fetch": { - "optional": true - } - } - }, - "node_modules/fetch-mock/node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", - "dev": true - }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -23341,6 +23720,7 @@ "url": "https://opencollective.com/formik" } ], + "license": "Apache-2.0", "dependencies": { "deepmerge": "^2.1.1", "hoist-non-react-statics": "^3.3.0", @@ -23359,6 +23739,7 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -23367,7 +23748,8 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "dev": true, + "license": "0BSD" }, "node_modules/forwarded": { "version": "0.2.0", @@ -23475,11 +23857,12 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -24546,7 +24929,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/handlebars": { "version": "4.7.8", @@ -24926,8 +25310,9 @@ "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", "dev": true, + "license": "MIT", "dependencies": { "inherits": "^2.0.1", "obuf": "^1.0.0", @@ -24938,14 +25323,16 @@ "node_modules/hpack.js/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" }, "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -24961,6 +25348,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } @@ -25057,6 +25445,7 @@ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "dev": true, + "license": "MIT", "dependencies": { "void-elements": "3.1.0" } @@ -25119,8 +25508,9 @@ "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=", - "dev": true + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.0", @@ -25189,10 +25579,11 @@ "dev": true }, "node_modules/http-parser-js": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.5.tgz", - "integrity": "sha512-x+JVEkO2PoM8qqpbPbOL3cqHPwerep7OwzK7Ay+sMQjKzaKCqWvjoXm5tqMP9tXWWTnTzAjIhXg+J99XYuPhPA==", - "dev": true + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" }, "node_modules/http-proxy": { "version": "1.18.1", @@ -25223,9 +25614,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", - "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -25322,9 +25713,10 @@ } }, "node_modules/hyphenate-style-name": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" }, "node_modules/i18next": { "version": "23.10.0", @@ -25676,6 +26068,7 @@ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", "dev": true, + "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" } @@ -25855,33 +26248,6 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, - "node_modules/is-arrow-function": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-arrow-function/-/is-arrow-function-2.0.3.tgz", - "integrity": "sha512-iDStzcT1FJMzx+TjCOK//uDugSe/Mif/8a+T0htydQ3qkJGvSweTZpVYz4hpJH0baloSPiAFQdA8WslAgJphvQ==", - "dev": true, - "dependencies": { - "is-callable": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-async-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", - "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", @@ -26031,42 +26397,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-equal": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/is-equal/-/is-equal-1.7.0.tgz", - "integrity": "sha512-hErktGR9jmoYXNWlbrwGjc8eHh09mbY6TWSTTFtnMcKaCuSMN8z+Ni5ma/8mkbVpe4CbB7V6kN1MkCg9bCx5bA==", - "dev": true, - "dependencies": { - "es-get-iterator": "^1.1.3", - "es-to-primitive": "^1.2.1", - "functions-have-names": "^1.2.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "is-arrow-function": "^2.0.3", - "is-bigint": "^1.0.4", - "is-boolean-object": "^1.1.2", - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-generator-function": "^1.0.10", - "is-number-object": "^1.0.7", - "is-regex": "^1.1.4", - "is-string": "^1.0.7", - "is-symbol": "^1.0.4", - "isarray": "^2.0.5", - "object-inspect": "^1.13.1", - "object.entries": "^1.1.7", - "object.getprototypeof": "^1.0.5", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-expression": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", @@ -26096,18 +26426,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", - "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -26360,7 +26678,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-shallow-equal/-/is-shallow-equal-1.0.1.tgz", "integrity": "sha512-lq5RvK+85Hs5J3p4oA4256M1FEffzmI533ikeDHvJd42nouRRx5wBzt36JuviiGe5dIPyHON/d0/Up+PBo6XkQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-shared-array-buffer": { "version": "1.0.3", @@ -26407,12 +26726,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-subset": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", - "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=", - "dev": true - }, "node_modules/is-symbol": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", @@ -27577,12 +27890,6 @@ "node": ">=12.0.0" } }, - "node_modules/keycode": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", - "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==", - "dev": true - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -27733,10 +28040,11 @@ "link": true }, "node_modules/launch-editor": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.8.0.tgz", - "integrity": "sha512-vJranOAJrI/llyWGRQqiDM+adrw+k83fvmmx3+nV47g3+36xM15jE+zyZ6Ffel02+xSvuM0b2GDRosXZkbb6wA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", + "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", "dev": true, + "license": "MIT", "dependencies": { "picocolors": "^1.0.0", "shell-quote": "^1.8.1" @@ -28415,12 +28723,6 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", - "dev": true - }, "node_modules/lodash.support": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.4.1.tgz", @@ -28852,6 +29154,7 @@ "version": "0.4.2", "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz", "integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==", + "license": "MIT", "dependencies": { "css-mediaquery": "^0.1.2" } @@ -28860,7 +29163,8 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/mathjax": { "version": "3.2.2", @@ -28923,6 +29227,13 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "dev": true, + "license": "MIT" + }, "node_modules/memoizee": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", @@ -29492,8 +29803,9 @@ "node_modules/mime-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz", - "integrity": "sha1-P4fDHprxpf1IX7nbE0Qosju7e6g=", + "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==", "dev": true, + "license": "ISC", "dependencies": { "wildcard": "^1.1.0" } @@ -29625,7 +29937,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/minimatch": { "version": "3.1.2", @@ -30226,6 +30539,7 @@ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", "dev": true, + "license": "MIT", "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" @@ -30285,7 +30599,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz", "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nan": { "version": "2.17.0", @@ -30298,6 +30613,25 @@ "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==", "dev": true }, + "node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, "node_modules/nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -30558,10 +30892,11 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", - "dev": true + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" }, "node_modules/nodemailer": { "version": "6.9.9", @@ -30994,24 +31329,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.getprototypeof": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/object.getprototypeof/-/object.getprototypeof-1.0.5.tgz", - "integrity": "sha512-4G0QiXpoIppBUz5efmxTm/HTbVN2ioGjk/PbsaNvwISFX+saj8muGp6vNuzIdsosFxM4V/kpUVNvy/+9+DVBZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "reflect.getprototypeof": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object.hasown": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", @@ -31065,7 +31382,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/octonode": { "version": "0.9.5", @@ -31924,16 +32242,16 @@ } }, "node_modules/pdfjs-dist": { - "version": "4.10.38", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", - "integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==", + "version": "5.1.91", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.1.91.tgz", + "integrity": "sha512-qSIADdagooJB4wWCBnrBJjRvASevmxL0BwafvOuKJG5uTQdYoFBrhrRYnucKNiSc9qS6JIk0hC5y1yktFljXkA==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.65" + "@napi-rs/canvas": "^0.1.67" } }, "node_modules/pend": { @@ -32045,9 +32363,10 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -32272,7 +32591,7 @@ "node_modules/policyfile": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/policyfile/-/policyfile-0.0.4.tgz", - "integrity": "sha1-1rgurZiueeviKOLa9ZAzEeyYLk0=", + "integrity": "sha512-UfDtlscNialXfmVEwEPm0t/5qtM0xPK025eYWd/ilv89hxLIhVQmt3QIzMHincLO2MBtZyww0386pt13J4aIhQ==", "engines": { "node": "*" } @@ -32309,9 +32628,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -32326,10 +32645,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -33555,15 +33875,16 @@ "dev": true }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -33646,6 +33967,17 @@ "node": ">= 8" } }, + "node_modules/preact": { + "version": "10.26.5", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.5.tgz", + "integrity": "sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/precond": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", @@ -33869,6 +34201,7 @@ "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", "dev": true, + "license": "MIT", "dependencies": { "react-is": "^16.3.2", "warning": "^4.0.0" @@ -33881,16 +34214,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - }, - "node_modules/prop-types-extra/node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "dev": true, - "dependencies": { - "loose-envify": "^1.0.0" - } + "license": "MIT" }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", @@ -34512,12 +34837,6 @@ } ] }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", - "dev": true - }, "node_modules/quick-lru": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", @@ -34596,12 +34915,12 @@ } }, "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" }, "engines": { "node": ">=0.10.0" @@ -34612,6 +34931,7 @@ "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", "dev": true, + "license": "MIT", "dependencies": { "hoist-non-react-statics": "^3.3.0", "prop-types": "^15.5.0" @@ -34620,28 +34940,36 @@ "react": ">=16.4.1" } }, - "node_modules/react-bootstrap": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz", - "integrity": "sha512-qWTRravSds87P8WC82tETy2yIso8qDqlIm0czsrduCaYAFtHuyLu0XDbUlfLXeRzqgwm5sRk2wRaTNoiVkk/YQ==", + "node_modules/react-bootstrap-5": { + "name": "react-bootstrap", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz", + "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime-corejs2": "^7.0.0", - "classnames": "^2.2.5", - "dom-helpers": "^3.2.0", + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.6.9", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", "invariant": "^2.2.4", - "keycode": "^2.2.0", - "prop-types": "^15.6.1", - "prop-types-extra": "^1.0.1", - "react-overlays": "^0.9.0", - "react-prop-types": "^0.4.0", - "react-transition-group": "^2.0.0", - "uncontrollable": "^7.0.2", - "warning": "^3.0.0" + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" }, "peerDependencies": { - "react": ">=16.3.0", - "react-dom": ">=16.3.0" + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/react-chartjs-2": { @@ -34649,6 +34977,7 @@ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.0.1.tgz", "integrity": "sha512-u38C9OxynlNCBp+79grgXRs7DSJ9w8FuQ5/HO5FbYBbri8HSZW+9SWgjVshLkbXBfXnMGWakbHEtvN0nL2UG7Q==", "dev": true, + "license": "MIT", "peerDependencies": { "chart.js": "^4.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" @@ -34659,6 +34988,7 @@ "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", "dev": true, + "license": "MIT", "dependencies": { "@icons/material": "^0.2.4", "lodash": "^4.17.15", @@ -34672,6 +35002,20 @@ "react": "*" } }, + "node_modules/react-cookie": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.2.0.tgz", + "integrity": "sha512-mqhPERUyfOljq5yJ4woDFI33bjEtigsl8JDJdPPeNhr0eSVZmBc/2Vdf8mFxOUktQxhxTR1T+uF0/FRTZyBEgw==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.5", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^7.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/react-deep-force-update": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/react-deep-force-update/-/react-deep-force-update-1.1.2.tgz", @@ -34683,6 +35027,7 @@ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", "dev": true, + "license": "MIT", "dependencies": { "@react-dnd/invariant": "^4.0.1", "@react-dnd/shallowequal": "^4.0.1", @@ -34713,6 +35058,7 @@ "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", "dev": true, + "license": "MIT", "dependencies": { "dnd-core": "^16.0.1" } @@ -34750,29 +35096,26 @@ } }, "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "17.0.2" + "react": "^18.3.1" } }, "node_modules/react-error-boundary": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-2.3.2.tgz", - "integrity": "sha512-ZMzi7s4pj/6A/6i9RS4tG7g1PdF2Rgr4/7FTQ8sbKHex19uNji0j+xq0OS//c6TUgQRKoL6P51BNNNFmYpRMhw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz", + "integrity": "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.11.2" - }, - "engines": { - "node": ">=10", - "npm": ">=6" + "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" @@ -34782,13 +35125,15 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/react-google-recaptcha": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", "dev": true, + "license": "MIT", "dependencies": { "prop-types": "^15.5.0", "react-async-script": "^1.2.0" @@ -34802,6 +35147,7 @@ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.3.1.tgz", "integrity": "sha512-JAtYREK879JXaN9GdzfBI4yJeo/XyLeXWUsRABvYXiFUakhZJ40l+kaTo+i+A/3cKIED41kS/HAbZ5BzFtq/Og==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.22.5", "html-parse-stringify": "^3.0.1" @@ -34828,13 +35174,15 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/react-linkify": { "version": "1.0.0-alpha", "resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-1.0.0-alpha.tgz", "integrity": "sha512-7gcIUvJkAXXttt1fmBK9cwn+1jTa4hbKLGCZ9J1U6EOkyb2/+LKL1Z28d9rtDLMnpvImlNlLPdTPooorl5cpmg==", "dev": true, + "license": "MIT", "dependencies": { "linkify-it": "^2.0.3", "tlds": "^1.199.0" @@ -34845,40 +35193,11 @@ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==", "dev": true, + "license": "MIT", "dependencies": { "uc.micro": "^1.0.1" } }, - "node_modules/react-overlays": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.3.tgz", - "integrity": "sha512-u2T7nOLnK+Hrntho4p0Nxh+BsJl0bl4Xuwj/Y0a56xywLMetgAfyjnDVrudLXsNcKGaspoC+t3C1V80W9QQTdQ==", - "dev": true, - "dependencies": { - "classnames": "^2.2.5", - "dom-helpers": "^3.2.1", - "prop-types": "^15.5.10", - "prop-types-extra": "^1.0.1", - "react-transition-group": "^2.2.1", - "warning": "^3.0.0" - }, - "peerDependencies": { - "react": ">=16.3.0", - "react-dom": ">=16.3.0" - } - }, - "node_modules/react-prop-types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", - "integrity": "sha1-+ZsL+0AGkpya8gUefBQUpcdbk9A=", - "dev": true, - "dependencies": { - "warning": "^3.0.0" - }, - "peerDependencies": { - "react": ">=0.14.0" - } - }, "node_modules/react-proxy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/react-proxy/-/react-proxy-1.1.8.tgz", @@ -34893,6 +35212,7 @@ "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -34927,15 +35247,17 @@ "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.1.tgz", "integrity": "sha512-+cUV/yZBYfiBj+WJtpWDJ3NtR4zgDZfHt3+xtaETKE+FCvp+RK/NJxacDQKxMHgRUTSkfA6AnGljQ5QZNsCQoA==", "dev": true, + "license": "MIT", "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/react-responsive": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.0.tgz", - "integrity": "sha512-N6/UiRLGQyGUqrarhBZmrSmHi2FXSD++N5VbSKsBBvWfG0ZV7asvUBluSv5lSzdMyEVjzZ6Y8DL4OHABiztDOg==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.1.tgz", + "integrity": "sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g==", + "license": "MIT", "dependencies": { "hyphenate-style-name": "^1.0.0", "matchmediaquery": "^0.4.2", @@ -34950,11 +35272,12 @@ } }, "node_modules/react-router": { - "version": "6.26.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz", - "integrity": "sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==", + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", + "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.19.1" + "@remix-run/router": "1.23.0" }, "engines": { "node": ">=14.0.0" @@ -34964,12 +35287,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.26.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.1.tgz", - "integrity": "sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==", + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", + "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.19.1", - "react-router": "6.26.1" + "@remix-run/router": "1.23.0", + "react-router": "6.30.0" }, "engines": { "node": ">=14.0.0" @@ -34990,19 +35314,20 @@ } }, "node_modules/react-transition-group": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", - "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "dom-helpers": "^3.4.0", + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", - "prop-types": "^15.6.2", - "react-lifecycles-compat": "^3.0.4" + "prop-types": "^15.6.2" }, "peerDependencies": { - "react": ">=15.0.0", - "react-dom": ">=15.0.0" + "react": ">=16.6.0", + "react-dom": ">=16.6.0" } }, "node_modules/reactcss": { @@ -35010,6 +35335,7 @@ "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", "dev": true, + "license": "MIT", "dependencies": { "lodash": "^4.0.1" } @@ -35247,6 +35573,15 @@ "node": ">=8" } }, + "node_modules/redis": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/redis/-/redis-0.7.3.tgz", + "integrity": "sha512-0Pgb0jOLfn6eREtEIRn/ifyZJjl2H+wUY4F/Pe7T4UhmoSrZ/1HU5ZqiBpDk8I8Wbyv2N5DpXKzbEtMj3drprg==", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/redis-commands": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", @@ -35341,37 +35676,19 @@ "node": ">=4.0.0" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", - "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "globalthis": "^1.0.3", - "which-builtin-type": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true + "dev": true, + "license": "MIT" }, "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.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", "dev": true, + "license": "MIT", "dependencies": { "regenerate": "^1.4.2" }, @@ -35389,6 +35706,7 @@ "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" } @@ -35439,6 +35757,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -35452,15 +35780,16 @@ } }, "node_modules/regexpu-core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", - "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/regjsgen": "^0.8.0", "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.1.0", - "regjsparser": "^0.9.1", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.1.0" }, @@ -35468,25 +35797,37 @@ "node": ">=4" } }, - "node_modules/regjsparser": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", - "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~0.5.0" + "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" } }, "node_modules/regjsparser/node_modules/jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" } }, "node_modules/relateurl": { @@ -35823,6 +36164,46 @@ "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", "integrity": "sha1-8z/pz7Urv9UgqhgyO8ZdsRCht2w=" }, + "node_modules/rollup": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz", + "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.0", + "@rollup/rollup-android-arm64": "4.40.0", + "@rollup/rollup-darwin-arm64": "4.40.0", + "@rollup/rollup-darwin-x64": "4.40.0", + "@rollup/rollup-freebsd-arm64": "4.40.0", + "@rollup/rollup-freebsd-x64": "4.40.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", + "@rollup/rollup-linux-arm-musleabihf": "4.40.0", + "@rollup/rollup-linux-arm64-gnu": "4.40.0", + "@rollup/rollup-linux-arm64-musl": "4.40.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", + "@rollup/rollup-linux-riscv64-gnu": "4.40.0", + "@rollup/rollup-linux-riscv64-musl": "4.40.0", + "@rollup/rollup-linux-s390x-gnu": "4.40.0", + "@rollup/rollup-linux-x64-gnu": "4.40.0", + "@rollup/rollup-linux-x64-musl": "4.40.0", + "@rollup/rollup-win32-arm64-msvc": "4.40.0", + "@rollup/rollup-win32-ia32-msvc": "4.40.0", + "@rollup/rollup-win32-x64-msvc": "4.40.0", + "fsevents": "~2.3.2" + } + }, "node_modules/route-recognizer": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/route-recognizer/-/route-recognizer-0.3.4.tgz", @@ -36203,12 +36584,12 @@ } }, "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/schema-utils": { @@ -36241,8 +36622,9 @@ "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=", - "dev": true + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" }, "node_modules/selfsigned": { "version": "2.4.1", @@ -36523,8 +36905,9 @@ "node_modules/serve-index": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", "dev": true, + "license": "MIT", "dependencies": { "accepts": "~1.3.4", "batch": "0.6.1", @@ -36543,6 +36926,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -36550,8 +36934,9 @@ "node_modules/serve-index/node_modules/http-errors": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", "dev": true, + "license": "MIT", "dependencies": { "depd": "~1.1.2", "inherits": "2.0.3", @@ -36565,20 +36950,23 @@ "node_modules/serve-index/node_modules/inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" }, "node_modules/serve-index/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" }, "node_modules/serve-index/node_modules/setprototypeof": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/serve-static": { "version": "1.16.2", @@ -36712,7 +37100,8 @@ "node_modules/shallow-equal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", - "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==" + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", + "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -36734,10 +37123,14 @@ } }, "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -36770,6 +37163,13 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sigmund": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", @@ -37189,8 +37589,8 @@ } }, "node_modules/socket.io": { - "version": "0.9.19-overleaf-10", - "resolved": "git+ssh://git@github.com/overleaf/socket.io.git#7ac322c2a5b26a4647834868d78afbb0db1f8849", + "version": "0.9.19-overleaf-11", + "resolved": "git+ssh://git@github.com/overleaf/socket.io.git#5afa587036620afa232d0f7b778ebb1541d7e4d5", "dependencies": { "base64id": "0.1.0", "policyfile": "0.0.4" @@ -37222,20 +37622,12 @@ "ultron": "1.0.x" } }, - "node_modules/socket.io/node_modules/redis": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/redis/-/redis-0.7.3.tgz", - "integrity": "sha512-0Pgb0jOLfn6eREtEIRn/ifyZJjl2H+wUY4F/Pe7T4UhmoSrZ/1HU5ZqiBpDk8I8Wbyv2N5DpXKzbEtMj3drprg==", - "optional": true, - "engines": { - "node": "*" - } - }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", "dev": true, + "license": "MIT", "dependencies": { "faye-websocket": "^0.11.3", "uuid": "^8.3.2", @@ -37247,6 +37639,7 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } @@ -37293,9 +37686,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -37380,6 +37774,7 @@ "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.0", "handle-thing": "^2.0.0", @@ -37396,6 +37791,7 @@ "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.0", "detect-node": "^2.0.4", @@ -37495,11 +37891,19 @@ "node": "*" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/standard-as-callback": { "version": "2.1.0", @@ -37552,6 +37956,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -37565,13 +37976,13 @@ } }, "node_modules/storybook": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.4.tgz", - "integrity": "sha512-XXh1Acvf1r3BQX0BDLQw6yhZ7yUGvYxIcKOBuMdetnX7iXtczipJTfw0uyFwk0ltkKEE9PpJvivYmARF3u64VQ==", + "version": "8.6.12", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.12.tgz", + "integrity": "sha512-Z/nWYEHBTLK1ZBtAWdhxC0l5zf7ioJ7G4+zYqtTdYeb67gTnxNj80gehf8o8QY9L2zA2+eyMRGLC2V5fI7Z3Tw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core": "8.6.4" + "@storybook/core": "8.6.12" }, "bin": { "getstorybook": "bin/index.cjs", @@ -37639,13 +38050,16 @@ } }, "node_modules/streamx": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", - "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", - "dev": true, + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "license": "MIT", "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -38721,6 +39135,31 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", + "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -38980,6 +39419,15 @@ "node": ">=8" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -39146,7 +39594,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tildify": { "version": "2.0.0", @@ -39206,13 +39655,22 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", @@ -39221,6 +39679,61 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyrainbow": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", @@ -39242,20 +39755,15 @@ } }, "node_modules/tlds": { - "version": "1.228.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.228.0.tgz", - "integrity": "sha512-Q0TU9zh5hDs2CpRFNM7SOW3K7OSgUgJC/cMrq9t44ei4tu+G3KV8BZyIJuYVvryJHH96mKgc9WXdhgKVvGD7jg==", + "version": "1.256.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.256.0.tgz", + "integrity": "sha512-ZmyVB9DAw+FFTmLElGYJgdZFsKLYd/I59Bg9NHkCGPwAbVZNRilFWDMAdX8UG+bHuv7kfursd5XGqo/9wi26lA==", "dev": true, + "license": "MIT", "bin": { "tlds": "bin.js" } }, - "node_modules/tmatch": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/tmatch/-/tmatch-2.0.1.tgz", - "integrity": "sha512-OHn/lzGWAsh5MBNTXUiHc595HAbIASCs6M+hDrkMObbSzsXej0SCKrQxr4J6EmRHbdo3qwyetPzuzEktkZiy4g==", - "dev": true - }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -39443,15 +39951,6 @@ "node": ">= 4.0.0" } }, - "node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/traverse": { "version": "0.6.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", @@ -39533,9 +40032,10 @@ } }, "node_modules/tslib": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz", - "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", @@ -39735,12 +40235,13 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ufo": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", - "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "dev": true, "license": "MIT" }, @@ -39807,6 +40308,7 @@ "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", @@ -39837,10 +40339,11 @@ } }, "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", - "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -39850,6 +40353,7 @@ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "dev": true, + "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -39859,10 +40363,11 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", - "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -39872,6 +40377,7 @@ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -39913,6 +40419,25 @@ "node": ">=0.10.0" } }, + "node_modules/universal-cookie": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz", + "integrity": "sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^0.7.2" + } + }, + "node_modules/universal-cookie/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -40020,9 +40545,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -40038,9 +40563,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -40452,6 +40978,177 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/vite-node": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz", + "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/vite-node/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", + "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.3", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.12" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -40503,10 +41200,11 @@ } }, "node_modules/warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", - "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "dev": true, + "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" } @@ -40529,6 +41227,7 @@ "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", "dev": true, + "license": "MIT", "dependencies": { "minimalistic-assert": "^1.0.0" } @@ -40833,9 +41532,9 @@ } }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -40853,15 +41552,16 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz", - "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz", + "integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==", "dev": true, "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", "@types/express": "^4.17.21", + "@types/express-serve-static-core": "^4.17.21", "@types/serve-index": "^1.9.4", "@types/serve-static": "^1.15.5", "@types/sockjs": "^0.3.36", @@ -40999,9 +41699,9 @@ "license": "MIT" }, "node_modules/webpack-dev-server/node_modules/open": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", - "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.1.tgz", + "integrity": "sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==", "dev": true, "license": "MIT", "dependencies": { @@ -41018,9 +41718,9 @@ } }, "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -41197,6 +41897,7 @@ "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", "safe-buffer": ">=5.1.0", @@ -41211,6 +41912,7 @@ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=0.8.0" } @@ -41248,23 +41950,6 @@ "node": ">=12" } }, - "node_modules/whatwg-url": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", - "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", - "dev": true, - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/whatwg-url/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -41294,32 +41979,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-builtin-type": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", - "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", - "dev": true, - "dependencies": { - "function.prototype.name": "^1.1.5", - "has-tostringtag": "^1.0.0", - "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", - "is-finalizationregistry": "^1.0.2", - "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/which-collection": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", @@ -41360,6 +42019,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", @@ -41410,8 +42086,9 @@ "node_modules/wildcard": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz", - "integrity": "sha1-pwIEUwhNjNLv5wup02liY94XEKU=", - "dev": true + "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==", + "dev": true, + "license": "MIT" }, "node_modules/with": { "version": "7.0.2", @@ -42015,13 +42692,13 @@ "async": "^3.2.5", "body-parser": "^1.20.3", "bunyan": "^1.8.15", - "diskusage": "^1.1.3", "dockerode": "^4.0.5", "express": "^4.21.2", "lodash": "^4.17.21", "p-limit": "^3.1.0", "request": "^2.88.2", "send": "^0.19.0", + "tar-fs": "^3.0.4", "workerpool": "^6.1.5" }, "devDependencies": { @@ -42038,6 +42715,27 @@ "typescript": "^5.0.4" } }, + "services/clsi-cache": { + "name": "@overleaf/clsi-cache", + "dependencies": { + "@overleaf/fetch-utils": "*", + "@overleaf/logger": "*", + "@overleaf/metrics": "*", + "@overleaf/o-error": "*", + "@overleaf/promise-utils": "*", + "@overleaf/settings": "*", + "body-parser": "^1.20.3", + "bunyan": "^1.8.15", + "celebrate": "^15.0.3", + "express": "^4.21.2", + "p-limit": "^3.1.0" + }, + "devDependencies": { + "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", + "mocha": "^11.1.0" + } + }, "services/clsi-perf": { "name": "@overleaf/clsi-perf", "dependencies": { @@ -42136,6 +42834,18 @@ "node": ">= 8.0" } }, + "services/clsi/node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, "services/clsi/node_modules/nan": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", @@ -42215,18 +42925,6 @@ "node": ">=8" } }, - "services/clsi/node_modules/tar-fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", - "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, "services/clsi/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -42912,9 +43610,9 @@ "jsonwebtoken": "^9.0.2", "method-override": "^2.3.10", "prop-types": "^15.8.1", - "react": "^17.0.2", + "react": "^18.3.1", "react-cookie": "^7.2.0", - "react-dom": "^17.0.2", + "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-helmet": "^6.1.0", "react-redux": "^7.2.2", @@ -42936,12 +43634,12 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-react": "^7.24.7", "@babel/register": "^7.24.6", - "@testing-library/react": "^12.1.5", + "@testing-library/react": "^16.3.0", + "@vitejs/plugin-react": "^4.3.4", "babel-loader": "^9.1.3", "babel-plugin-react-transform": "^2.0.2", "babel-plugin-transform-react-remove-prop-types": "^0.3.3", - "chai": "^4.3.10", - "chai-as-promised": "^7.1.1", + "combobo": "^2.0.4", "css-loader": "^6.8.1", "cssnano": "^6.0.0", "eslint": "^7.21.0", @@ -42949,11 +43647,8 @@ "eslint-config-standard": "^16.0.3", "eslint-plugin-react": "^7.32.2", "eslint-plugin-unicorn": "^56.0.0", - "expect": "^1.15.2", "file-loader": "^6.2.0", - "jsdom": "^20.0.0", "mini-css-extract-plugin": "^2.7.6", - "mocha": "^11.1.0", "nodemon": "^3.0.1", "postcss": "^8.4.31", "postcss-import": "^15.1.0", @@ -42963,10 +43658,9 @@ "react-transform-hmr": "^1.0.4", "redux-mock-store": "1.5.0", "sandboxed-module": "^2.0.4", - "sinon": "^19.0.2", - "sinon-chai": "^3.7.0", "style-loader": "^3.3.3", "url-loader": "^4.1.1", + "vitest": "^3.1.2", "webpack": "^5.98.0", "webpack-cli": "^5.1.4", "webpack-dev-middleware": "^6.1.2", @@ -42986,6 +43680,197 @@ "@babel/highlight": "^7.10.4" } }, + "services/latexqc/node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "services/latexqc/node_modules/@babel/core/node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "services/latexqc/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "services/latexqc/node_modules/@babel/generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", + "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "services/latexqc/node_modules/@babel/helpers": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "services/latexqc/node_modules/@babel/parser": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "services/latexqc/node_modules/@babel/template": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "services/latexqc/node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "services/latexqc/node_modules/@babel/traverse": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", + "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.27.0", + "@babel/parser": "^7.27.0", + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "services/latexqc/node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "services/latexqc/node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "services/latexqc/node_modules/@babel/types": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "services/latexqc/node_modules/@eslint/eslintrc": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", @@ -43021,57 +43906,120 @@ "node": ">=10.10.0" } }, - "services/latexqc/node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "services/latexqc/node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "dev": true, + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "type-detect": "4.0.8" + "@types/ms": "*" } }, - "services/latexqc/node_modules/@sinonjs/commons/node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "services/latexqc/node_modules/@vitejs/plugin-react": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", + "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.10", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, "engines": { - "node": ">=4" + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, - "services/latexqc/node_modules/@sinonjs/fake-timers": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", - "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", + "services/latexqc/node_modules/@vitest/expect": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz", + "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==", "dev": true, + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.1" + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "services/latexqc/node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "services/latexqc/node_modules/@vitest/mocker": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz", + "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==", "dev": true, + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", - "type-detect": "^4.1.0" + "@vitest/spy": "3.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "services/latexqc/node_modules/@sinonjs/text-encoding": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", - "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", - "dev": true - }, - "services/latexqc/node_modules/@types/hoist-non-react-statics": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", - "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "services/latexqc/node_modules/@vitest/pretty-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/latexqc/node_modules/@vitest/spy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz", + "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/latexqc/node_modules/@vitest/utils": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.2", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "services/latexqc/node_modules/acorn-globals": { @@ -43079,6 +44027,8 @@ "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "acorn": "^8.1.0", "acorn-walk": "^8.0.2" @@ -43089,6 +44039,8 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "optional": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -43101,6 +44053,8 @@ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "acorn": "^8.11.0" }, @@ -43113,6 +44067,8 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "optional": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -43144,6 +44100,16 @@ "sprintf-js": "~1.0.2" } }, + "services/latexqc/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "services/latexqc/node_modules/attr-accept": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", @@ -43172,21 +44138,20 @@ } }, "services/latexqc/node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, + "license": "MIT", "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=12" } }, "services/latexqc/node_modules/chalk": { @@ -43205,14 +44170,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "services/latexqc/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "services/latexqc/node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 16" } }, + "services/latexqc/node_modules/convert-source-map": { + "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, + "license": "MIT" + }, "services/latexqc/node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -43239,31 +44213,22 @@ "license": "MIT" }, "services/latexqc/node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, + "license": "MIT", "engines": { "node": ">=6" } }, - "services/latexqc/node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "services/latexqc/node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -43390,6 +44355,21 @@ "node": ">=4" } }, + "services/latexqc/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "services/latexqc/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -43418,6 +44398,8 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -43435,6 +44417,18 @@ "node": ">= 4" } }, + "services/latexqc/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "services/latexqc/node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", @@ -43453,6 +44447,8 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", @@ -43498,6 +44494,8 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "optional": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -43511,11 +44509,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "services/latexqc/node_modules/just-extend": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", - "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", - "dev": true + "services/latexqc/node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" }, "services/latexqc/node_modules/method-override": { "version": "2.3.10", @@ -43544,19 +44543,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "services/latexqc/node_modules/nise": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", - "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.1", - "@sinonjs/text-encoding": "^0.7.3", - "just-extend": "^6.2.0", - "path-to-regexp": "^8.1.0" - } - }, "services/latexqc/node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -43579,6 +44565,8 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "entities": "^4.4.0" }, @@ -43586,26 +44574,27 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "services/latexqc/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "services/latexqc/node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">= 14.16" } }, - "services/latexqc/node_modules/react-cookie": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.2.0.tgz", - "integrity": "sha512-mqhPERUyfOljq5yJ4woDFI33bjEtigsl8JDJdPPeNhr0eSVZmBc/2Vdf8mFxOUktQxhxTR1T+uF0/FRTZyBEgw==", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.5", - "hoist-non-react-statics": "^3.3.2", - "universal-cookie": "^7.0.0" + "services/latexqc/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, - "peerDependencies": { - "react": ">= 16.3.0" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "services/latexqc/node_modules/react-dropzone": { @@ -43651,11 +44640,23 @@ "react": "^16.3.0 || ^17.0.0 || ^18.0.0" } }, + "services/latexqc/node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "services/latexqc/node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -43723,24 +44724,6 @@ "node": ">=10" } }, - "services/latexqc/node_modules/sinon": { - "version": "19.0.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", - "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.2", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "nise": "^6.1.1", - "supports-color": "^7.2.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/sinon" - } - }, "services/latexqc/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -43753,11 +44736,23 @@ "node": ">=8" } }, + "services/latexqc/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "services/latexqc/node_modules/tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.1.1" }, @@ -43765,22 +44760,150 @@ "node": ">=12" } }, - "services/latexqc/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "services/latexqc/node_modules/vite": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", + "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.3", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.12" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=4" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "services/latexqc/node_modules/universal-cookie": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.0.tgz", - "integrity": "sha512-PvcyflJAYACJKr28HABxkGemML5vafHmiL4ICe3e+BEKXRMt0GaFLZhAwgv637kFFnnfiSJ8e6jknrKkMrU+PQ==", + "services/latexqc/node_modules/vitest": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz", + "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.6.0" + "@vitest/expect": "3.1.2", + "@vitest/mocker": "3.1.2", + "@vitest/pretty-format": "^3.1.2", + "@vitest/runner": "3.1.2", + "@vitest/snapshot": "3.1.2", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.1.2", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.1.2", + "@vitest/ui": "3.1.2", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "services/latexqc/node_modules/w3c-xmlserializer": { @@ -43788,6 +44911,8 @@ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^4.0.0" }, @@ -43828,6 +44953,8 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" @@ -43836,6 +44963,21 @@ "node": ">=12" } }, + "services/latexqc/node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "services/notifications": { "name": "@overleaf/notifications", "license": "ISC", @@ -43956,7 +45098,7 @@ "lodash": "^4.17.21", "proxy-addr": "^2.0.7", "request": "^2.88.2", - "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-10", + "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-11", "socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5" }, "devDependencies": { @@ -44420,12 +45562,12 @@ "yauzl": "^2.10.0" }, "devDependencies": { - "@babel/cli": "^7.24.8", - "@babel/core": "^7.25.2", - "@babel/preset-env": "^7.25.3", - "@babel/preset-react": "^7.24.7", - "@babel/preset-typescript": "^7.24.7", - "@babel/register": "^7.24.6", + "@babel/cli": "^7.27.0", + "@babel/core": "^7.26.10", + "@babel/preset-env": "^7.26.9", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.27.0", + "@babel/register": "^7.25.9", "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5", "@codemirror/commands": "^6.8.0", "@codemirror/lang-markdown": "^6.3.2", @@ -44444,7 +45586,7 @@ "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz", "@overleaf/ranges-tracker": "*", "@overleaf/stream-utils": "*", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.16", "@pollyjs/adapter-node-http": "^6.0.6", "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", @@ -44452,20 +45594,19 @@ "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161", "@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b", "@sentry/browser": "7.46.0", - "@storybook/addon-a11y": "^8.6.4", - "@storybook/addon-essentials": "^8.6.4", - "@storybook/addon-interactions": "^8.6.4", - "@storybook/addon-links": "^8.6.4", + "@storybook/addon-a11y": "^8.6.12", + "@storybook/addon-essentials": "^8.6.12", + "@storybook/addon-interactions": "^8.6.12", + "@storybook/addon-links": "^8.6.12", "@storybook/addon-styling-webpack": "^1.0.1", - "@storybook/addon-webpack5-compiler-babel": "^3.0.5", - "@storybook/cli": "^8.6.4", - "@storybook/react": "^8.6.4", - "@storybook/react-webpack5": "^8.6.4", - "@storybook/theming": "^8.6.4", + "@storybook/addon-webpack5-compiler-babel": "^3.0.6", + "@storybook/cli": "^8.6.12", + "@storybook/react": "^8.6.12", + "@storybook/react-webpack5": "^8.6.12", + "@storybook/theming": "^8.6.12", "@testing-library/cypress": "^10.0.1", - "@testing-library/dom": "^9.3.0", - "@testing-library/react": "^12.1.5", - "@testing-library/react-hooks": "^8.0.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.4.3", "@types/bootstrap": "^5.2.10", "@types/bootstrap-5": "npm:@types/bootstrap@^5.2.10", @@ -44476,13 +45617,12 @@ "@types/express": "^4.17.13", "@types/mocha": "^9.1.0", "@types/mocha-each": "^2.0.0", - "@types/react": "^17.0.40", - "@types/react-bootstrap": "^0.32.36", - "@types/react-color": "^3.0.6", - "@types/react-dom": "^17.0.13", - "@types/react-google-recaptcha": "^2.1.5", - "@types/react-linkify": "^1.0.0", - "@types/react-overlays": "^1.1.3", + "@types/react": "^18.3.20", + "@types/react-bootstrap": "^0.32.37", + "@types/react-color": "^3.0.13", + "@types/react-dom": "^18.3.6", + "@types/react-google-recaptcha": "^2.1.9", + "@types/react-linkify": "^1.0.4", "@types/recurly__recurly-js": "^4.22.0", "@types/sinon-chai": "^3.2.8", "@types/uuid": "^9.0.8", @@ -44517,7 +45657,7 @@ "classnames": "^2.2.6", "cookie-signature": "^1.2.1", "copy-webpack-plugin": "^11.0.0", - "core-js": "^3.38.1", + "core-js": "^3.41.0", "css-loader": "^6.8.1", "css-minimizer-webpack-plugin": "^5.0.1", "cypress": "13.13.2", @@ -44526,7 +45666,7 @@ "daterangepicker": "2.1.27", "diff": "^5.1.0", "dompurify": "^3.2.4", - "downshift": "^6.1.0", + "downshift": "^9.0.9", "es6-promise": "^4.2.8", "escodegen": "^2.0.0", "eslint-config-standard-jsx": "^11.0.0", @@ -44538,7 +45678,7 @@ "esmock": "^2.6.7", "events": "^3.3.0", "fake-indexeddb": "^6.0.0", - "fetch-mock": "^9.10.2", + "fetch-mock": "^12.5.2", "formik": "^2.2.9", "fuse.js": "^3.0.0", "glob": "^7.1.6", @@ -44563,25 +45703,23 @@ "nock": "^13.5.6", "nvd3": "^1.8.6", "overleaf-editor-core": "*", - "pdfjs-dist": "4.10.38", + "pdfjs-dist": "5.1.91", "pirates": "^4.0.1", "postcss": "^8.4.31", "postcss-loader": "^7.3.3", "prop-types": "^15.7.2", "qrcode": "^1.4.4", - "react": "^17.0.2", - "react-bootstrap": "^0.33.1", + "react": "^18.3.1", "react-bootstrap-5": "npm:react-bootstrap@^2.10.5", "react-chartjs-2": "^5.0.1", "react-color": "^2.19.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "^17.0.2", - "react-error-boundary": "^2.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^5.0.0", "react-google-recaptcha": "^3.1.0", "react-i18next": "^13.3.1", "react-linkify": "^1.0.0-alpha", - "react-overlays": "^0.9.3", "react-refresh": "^0.14.0", "react-resizable-panels": "^2.1.1", "resolve-url-loader": "^5.0.0", @@ -44593,7 +45731,7 @@ "sinon": "^7.5.0", "sinon-chai": "^3.7.0", "sinon-mongoose": "^2.3.0", - "storybook": "^8.6.4", + "storybook": "^8.6.12", "stylelint-config-standard-scss": "^13.1.0", "terser-webpack-plugin": "^5.3.9", "thread-loader": "^4.0.2", @@ -44606,23 +45744,11 @@ "webpack": "^5.98.0", "webpack-assets-manifest": "^5.2.1", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.2.0", + "webpack-dev-server": "^5.2.1", "webpack-merge": "^5.10.0", "yup": "^0.32.11" } }, - "services/web/node_modules/@babel/runtime": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", - "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "services/web/node_modules/@google-cloud/bigquery": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-6.0.3.tgz", @@ -44812,30 +45938,12 @@ "lodash": "^4.17.15" } }, - "services/web/node_modules/@testing-library/dom": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.0.tgz", - "integrity": "sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==", + "services/web/node_modules/@transloadit/prettier-bytes": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", + "integrity": "sha512-pCvdmea/F3Tn4hAtHqNXmjcixSaroJJ+L3STXlYJdir1g1m2mRQpWbN8a4SvgQtaw2930Ckhdx8qXdXBFMKbAA==", "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "^5.0.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "services/web/node_modules/@types/aria-query": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", - "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==", - "dev": true + "license": "MIT" }, "services/web/node_modules/@types/express": { "version": "4.17.21", @@ -44855,22 +45963,12 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, - "services/web/node_modules/@uppy/companion-client": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-3.7.0.tgz", - "integrity": "sha512-37qJNMkqo01SM9h2gkFbV6e+aXM02s2zAda2dGsRLRsjvl/Tx69NlmxJ3xqG/7HWRnYcbBWtspb7y0tt1i/afg==", - "dev": true, - "dependencies": { - "@uppy/utils": "^5.7.0", - "namespace-emitter": "^2.0.1", - "p-retry": "^6.1.0" - } - }, "services/web/node_modules/@uppy/core": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/@uppy/core/-/core-3.8.0.tgz", "integrity": "sha512-C93vVhid929+VLGjaD9CZOLJDg8GkEGMUGveFp3Tyo/wujiG+sB3fOF+c6TzKpzPLfNtVpskU1BnI7tZrq1LWw==", "dev": true, + "license": "MIT", "dependencies": { "@transloadit/prettier-bytes": "0.0.9", "@uppy/store-default": "^3.2.0", @@ -44882,17 +45980,12 @@ "preact": "^10.5.13" } }, - "services/web/node_modules/@uppy/core/node_modules/@transloadit/prettier-bytes": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", - "integrity": "sha512-pCvdmea/F3Tn4hAtHqNXmjcixSaroJJ+L3STXlYJdir1g1m2mRQpWbN8a4SvgQtaw2930Ckhdx8qXdXBFMKbAA==", - "dev": true - }, "services/web/node_modules/@uppy/dashboard": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-3.7.1.tgz", "integrity": "sha512-qtCMXd2Ymrw0qNGSTlEEMyyDkGUCm+wX5/VrmV9lnfT7JtlSfotUK0K6KvkBeu2v1Chsu27C6Xlq6RddZMR2xQ==", "dev": true, + "license": "MIT", "dependencies": { "@transloadit/prettier-bytes": "0.0.7", "@uppy/informer": "^3.0.4", @@ -44911,11 +46004,19 @@ "@uppy/core": "^3.7.1" } }, + "services/web/node_modules/@uppy/dashboard/node_modules/@transloadit/prettier-bytes": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz", + "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==", + "dev": true, + "license": "MIT" + }, "services/web/node_modules/@uppy/drag-drop": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@uppy/drag-drop/-/drag-drop-3.0.3.tgz", "integrity": "sha512-0bCgQKxg+9vkxQipTgrX9yQIuK9a0hZrkipm1+Ynq6jTeig49b7II1bWYnoKdiYhi6nRE4UnDJf4z09yCAU7rA==", "dev": true, + "license": "MIT", "dependencies": { "@uppy/utils": "^5.4.3", "preact": "^10.5.13" @@ -44929,19 +46030,7 @@ "resolved": "https://registry.npmjs.org/@uppy/file-input/-/file-input-3.0.4.tgz", "integrity": "sha512-D7Nw9GgpABYTcC8SZluDyxd+ppe7+gJejNbPZqMpQyW1S/ME3me55dkDQaVWn8yrgv7347zO2ciue9Rfmko+rQ==", "dev": true, - "dependencies": { - "@uppy/utils": "^5.5.2", - "preact": "^10.5.13" - }, - "peerDependencies": { - "@uppy/core": "^3.6.0" - } - }, - "services/web/node_modules/@uppy/informer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@uppy/informer/-/informer-3.0.4.tgz", - "integrity": "sha512-gzocdxn8qAFsW2EryehwjghladaBgv6Isjte53FTBV7o/vjaHPP6huKGbYpljyuQi8i9V+KrmvNGslofssgJ4g==", - "dev": true, + "license": "MIT", "dependencies": { "@uppy/utils": "^5.5.2", "preact": "^10.5.13" @@ -44955,6 +46044,7 @@ "resolved": "https://registry.npmjs.org/@uppy/progress-bar/-/progress-bar-3.0.4.tgz", "integrity": "sha512-sxv/mG7Uc9uyTnRvfcXBhO+TWd+UqjuW5aHXCKWwTkMgDShHR0T46sEk12q+jwgbFwyeFg3p0GU3hgUxqxiEUQ==", "dev": true, + "license": "MIT", "dependencies": { "@uppy/utils": "^5.5.2", "preact": "^10.5.13" @@ -44963,64 +46053,12 @@ "@uppy/core": "^3.6.0" } }, - "services/web/node_modules/@uppy/provider-views": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-3.8.0.tgz", - "integrity": "sha512-sTtx5bgsg2WVR+MyF0gnnM3Z7g3CyFx+Stlz//AvB6g27EMqtqO4zwDR3mestMrETkWYov5bhhqUbt2BaeANpA==", - "dev": true, - "dependencies": { - "@uppy/utils": "^5.7.0", - "classnames": "^2.2.6", - "nanoid": "^4.0.0", - "p-queue": "^7.3.4", - "preact": "^10.5.13" - }, - "peerDependencies": { - "@uppy/core": "^3.8.0" - } - }, - "services/web/node_modules/@uppy/provider-views/node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" - }, - "services/web/node_modules/@uppy/provider-views/node_modules/p-queue": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.4.1.tgz", - "integrity": "sha512-vRpMXmIkYF2/1hLBKisKeVYJZ8S2tZ0zEAmIJgdVKP2nq0nh4qCdf8bgw+ZgKrkh71AOCaqzwbJJk1WtdcF3VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^5.0.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "services/web/node_modules/@uppy/provider-views/node_modules/p-timeout": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz", - "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "services/web/node_modules/@uppy/react": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@uppy/react/-/react-3.2.1.tgz", "integrity": "sha512-PoLplDF6YDI7f06T8ORnJhav6CcKNSYWJETXqItZR3jcXIve6pdcCuskqd+l0yiYWf4J2IdyLQXtzgGfIJl7xQ==", "dev": true, + "license": "MIT", "dependencies": { "@uppy/utils": "^5.6.0", "prop-types": "^15.6.1" @@ -45052,51 +46090,12 @@ } } }, - "services/web/node_modules/@uppy/status-bar": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@uppy/status-bar/-/status-bar-3.2.5.tgz", - "integrity": "sha512-bRSxBPio5B+Kuf6w8ll+/i9VUwG8f0FnbZ1yQvCr8J9vxhd0Z5hvwhX4NP8uzHC6ZPJHlEQOTsxzGQ6y+Mdm0A==", - "dev": true, - "dependencies": { - "@transloadit/prettier-bytes": "0.0.9", - "@uppy/utils": "^5.5.2", - "classnames": "^2.2.6", - "preact": "^10.5.13" - }, - "peerDependencies": { - "@uppy/core": "^3.6.0" - } - }, - "services/web/node_modules/@uppy/status-bar/node_modules/@transloadit/prettier-bytes": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", - "integrity": "sha512-pCvdmea/F3Tn4hAtHqNXmjcixSaroJJ+L3STXlYJdir1g1m2mRQpWbN8a4SvgQtaw2930Ckhdx8qXdXBFMKbAA==", - "dev": true - }, - "services/web/node_modules/@uppy/store-default": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-3.2.0.tgz", - "integrity": "sha512-Y7t0peUG89ZKa30vM4qlRIC6uKxIfOANeMT9Nzjwcxvzz8l7es22jG3eAj9WF2F7YSu7xdsH8ODs6SIrJJ8gow==", - "dev": true - }, - "services/web/node_modules/@uppy/thumbnail-generator": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-3.0.6.tgz", - "integrity": "sha512-gsi/BQBiunHneXCbo8VglFbhEb0CoQXQjCyGNKoEq/deEcbXhBBDxkiGcgv83l5GZJl2jLiKWqXnXAXREkldrQ==", - "dev": true, - "dependencies": { - "@uppy/utils": "^5.5.2", - "exifr": "^7.0.0" - }, - "peerDependencies": { - "@uppy/core": "^3.6.0" - } - }, "services/web/node_modules/@uppy/utils": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-5.7.0.tgz", "integrity": "sha512-AJj7gAx5YfMgyevwOxVdIP2h4Nw/O6h57wKA6gj+Lce6tMORcqzGt4yQiKBsrBI0bPyFWCbzA3vX5t0//1JCBA==", "dev": true, + "license": "MIT", "dependencies": { "lodash": "^4.17.21", "preact": "^10.5.13" @@ -45107,6 +46106,7 @@ "resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-3.6.0.tgz", "integrity": "sha512-HgWr+CvJzJXAp639AiZatdEWmRdhhN5LrjTZurAkvm9nPQarpi1bo0DChO+1bpkXWOR/1VarBbZOr8lNecEn7Q==", "dev": true, + "license": "MIT", "dependencies": { "@uppy/companion-client": "^3.7.0", "@uppy/utils": "^5.7.0", @@ -45152,30 +46152,6 @@ "ajv": "^8.8.2" } }, - "services/web/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "services/web/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "services/web/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -45207,22 +46183,6 @@ "ieee754": "^1.2.1" } }, - "services/web/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "services/web/node_modules/csv": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/csv/-/csv-6.2.5.tgz", @@ -45269,16 +46229,6 @@ } } }, - "services/web/node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "services/web/node_modules/duplexify": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", @@ -45307,11 +46257,21 @@ "node": ">=0.8.x" } }, - "services/web/node_modules/exifr": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", - "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", - "dev": true + "services/web/node_modules/fetch-mock": { + "version": "12.5.2", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz", + "integrity": "sha512-b5KGDFmdmado2MPQjZl6ix3dAG3iwCitb0XQwN72y2s9VnWZ3ObaGNy+bkpm1390foiLDybdJ7yjRGKD36kATw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob-to-regexp": "^0.4.4", + "dequal": "^2.0.3", + "glob-to-regexp": "^0.4.1", + "regexparam": "^3.0.0" + }, + "engines": { + "node": ">=18.11.0" + } }, "services/web/node_modules/google-auth-library": { "version": "8.7.0", @@ -45435,12 +46395,6 @@ "node": ">=12" } }, - "services/web/node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "dev": true - }, "services/web/node_modules/method-override": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/method-override/-/method-override-2.3.10.tgz", @@ -45500,24 +46454,6 @@ "node": ">= 6.0.0" } }, - "services/web/node_modules/nanoid": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", - "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^14 || ^16 || >=18" - } - }, "services/web/node_modules/nise": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", @@ -45568,23 +46504,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "services/web/node_modules/p-retry": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.0.tgz", - "integrity": "sha512-JA6nkq6hKyWLLasXQXUrO4z8BUZGUt/LjlJxx8Gb2+2ntodU/SS63YZ8b0LUTbQ8ZB9iwOfhEPhg4ykKnn2KsA==", - "dev": true, - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "services/web/node_modules/path-to-regexp": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", @@ -45594,69 +46513,6 @@ "isarray": "0.0.1" } }, - "services/web/node_modules/preact": { - "version": "10.19.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", - "integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "services/web/node_modules/react-bootstrap-5": { - "name": "react-bootstrap", - "version": "2.10.5", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz", - "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.24.7", - "@restart/hooks": "^0.4.9", - "@restart/ui": "^1.6.9", - "@types/react-transition-group": "^4.4.6", - "classnames": "^2.3.2", - "dom-helpers": "^5.2.1", - "invariant": "^2.2.4", - "prop-types": "^15.8.1", - "prop-types-extra": "^1.1.0", - "react-transition-group": "^4.4.5", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "@types/react": ">=16.14.8", - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "services/web/node_modules/react-bootstrap-5/node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "services/web/node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, "services/web/node_modules/retry-request": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-5.0.2.tgz", @@ -45768,18 +46624,6 @@ "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.1.tgz", "integrity": "sha512-ApK+WTJ5bCOf0A2tlec1qhvr8bGEBM/sgXXB7mysdCYgZJO5DZeaV3h3G+g0HnAQ372P5IhiGqnW29zoLOfTzQ==" }, - "services/web/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "services/web/node_modules/teeny-request": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-8.0.2.tgz", @@ -45867,15 +46711,6 @@ "uuid": "dist/bin/uuid" } }, - "services/web/node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dev": true, - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "services/web/node_modules/xml-crypto": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.1.6.tgz", diff --git a/package.json b/package.json index ae25e19029..7dc9a63e29 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "services/analytics", "services/chat", "services/clsi", + "services/clsi-cache", "services/clsi-perf", "services/contacts", "services/docstore", diff --git a/patches/pdfjs-dist+5.1.91.patch b/patches/pdfjs-dist+5.1.91.patch new file mode 100644 index 0000000000..24f6e2adc9 --- /dev/null +++ b/patches/pdfjs-dist+5.1.91.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/pdfjs-dist/build/pdf.worker.mjs b/node_modules/pdfjs-dist/build/pdf.worker.mjs +index 6c5c6f1..bb6b7d1 100644 +--- a/node_modules/pdfjs-dist/build/pdf.worker.mjs ++++ b/node_modules/pdfjs-dist/build/pdf.worker.mjs +@@ -1830,7 +1830,7 @@ async function __wbg_init(module_or_path) { + } + } + if (typeof module_or_path === 'undefined') { +- module_or_path = new URL('qcms_bg.wasm', import.meta.url); ++ module_or_path = new URL(/* webpackIgnore: true */ 'qcms_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + if (typeof module_or_path === 'string' || typeof Request === 'function' && module_or_path instanceof Request || typeof URL === 'function' && module_or_path instanceof URL) { +@@ -5358,7 +5358,7 @@ var OpenJPEG = (() => { + if (Module["locateFile"]) { + return locateFile("openjpeg.wasm"); + } +- return new URL("openjpeg.wasm", import.meta.url).href; ++ return new URL(/* webpackIgnore: true */ "openjpeg.wasm", import.meta.url).href; + } + function getBinarySync(file) { + if (file == wasmBinaryFile && wasmBinary) { diff --git a/server-ce/config/custom-environment-variables.json b/server-ce/config/custom-environment-variables.json index 450e92fed9..f65f74ae2b 100644 --- a/server-ce/config/custom-environment-variables.json +++ b/server-ce/config/custom-environment-variables.json @@ -45,5 +45,17 @@ "clusterWorkers": "CLUSTER_WORKERS", "maxFileUploadSize": "MAX_FILE_UPLOAD_SIZE", "httpsOnly": "HTTPS_ONLY", - "httpRequestTimeout": "OVERLEAF_HISTORY_V1_HTTP_REQUEST_TIMEOUT" + "httpRequestTimeout": "OVERLEAF_HISTORY_V1_HTTP_REQUEST_TIMEOUT", + "redis": { + "history": { + "host": "OVERLEAF_REDIS_HOST", + "password": "OVERLEAF_REDIS_PASS", + "port": "OVERLEAF_REDIS_PORT" + }, + "lock": { + "host": "OVERLEAF_REDIS_HOST", + "password": "OVERLEAF_REDIS_PASS", + "port": "OVERLEAF_REDIS_PORT" + } + } } diff --git a/server-ce/test/accounts.spec.ts b/server-ce/test/accounts.spec.ts index eeeb104087..85d545535a 100644 --- a/server-ce/test/accounts.spec.ts +++ b/server-ce/test/accounts.spec.ts @@ -9,7 +9,7 @@ describe('Accounts', function () { it('can log in and out', function () { login('user@example.com') cy.visit('/project') - cy.findByText('Account').click() + cy.findByRole('menuitem', { name: 'Account' }).click() cy.findByText('Log Out').click() cy.url().should('include', '/login') cy.visit('/project') diff --git a/server-ce/test/admin.spec.ts b/server-ce/test/admin.spec.ts index 18b33c6932..8020deeb4b 100644 --- a/server-ce/test/admin.spec.ts +++ b/server-ce/test/admin.spec.ts @@ -293,7 +293,7 @@ describe('admin panel', function () { cy.findByText(deletedProjectName).should('not.exist') cy.log('navigate to thrashed projects and delete the project') - cy.get('.project-list-sidebar-react').within(() => { + cy.get('.project-list-sidebar-scroll').within(() => { cy.findByText('Trashed Projects').click() }) findProjectRow(deletedProjectName).within(() => @@ -318,7 +318,7 @@ describe('admin panel', function () { cy.log('login as the user and verify the project is restored') login(user1) cy.visit('/project') - cy.get('.project-list-sidebar-react').within(() => { + cy.get('.project-list-sidebar-scroll').within(() => { cy.findByText('Trashed Projects').click() }) cy.findByText(`${deletedProjectName} (Restored)`) diff --git a/server-ce/test/create-and-compile-project.spec.ts b/server-ce/test/create-and-compile-project.spec.ts index 20f8f0dd6d..2be4f208e2 100644 --- a/server-ce/test/create-and-compile-project.spec.ts +++ b/server-ce/test/create-and-compile-project.spec.ts @@ -102,10 +102,6 @@ describe('Project creation and compilation', function () { cy.findByText('Invite not yet accepted.') }) - cy.visit('/project') - cy.findByText('Account').click() - cy.findByText('Log Out').click() - login('collaborator@example.com') openProjectViaInviteNotification(targetProjectName) cy.get('@targetProjectId').then(targetProjectId => { diff --git a/server-ce/test/host-admin.js b/server-ce/test/host-admin.js index 9e4cd5d360..f73209d58f 100644 --- a/server-ce/test/host-admin.js +++ b/server-ce/test/host-admin.js @@ -131,9 +131,7 @@ const allowedVars = Joi.object( 'GIT_BRIDGE_HOST', 'GIT_BRIDGE_PORT', 'V1_HISTORY_URL', - 'DOCKER_RUNNER', 'SANDBOXED_COMPILES', - 'SANDBOXED_COMPILES_SIBLING_CONTAINERS', 'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES', 'OVERLEAF_TEMPLATES_USER_ID', 'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS', @@ -196,10 +194,7 @@ function setVarsDockerCompose({ pro, vars, version, withDataDir }) { ) } - if ( - cfg.services.sharelatex.environment - .SANDBOXED_COMPILES_SIBLING_CONTAINERS === 'true' - ) { + if (cfg.services.sharelatex.environment.SANDBOXED_COMPILES === 'true') { cfg.services.sharelatex.environment.SANDBOXED_COMPILES_HOST_DIR = PATHS.SANDBOXED_COMPILES_HOST_DIR cfg.services.sharelatex.environment.TEX_LIVE_DOCKER_IMAGE = diff --git a/server-ce/test/sandboxed-compiles.spec.ts b/server-ce/test/sandboxed-compiles.spec.ts index 505f8cffd2..f39a00161b 100644 --- a/server-ce/test/sandboxed-compiles.spec.ts +++ b/server-ce/test/sandboxed-compiles.spec.ts @@ -10,9 +10,7 @@ const LABEL_TEX_LIVE_VERSION = 'TeX Live version' describe('SandboxedCompiles', function () { const enabledVars = { - DOCKER_RUNNER: 'true', SANDBOXED_COMPILES: 'true', - SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true', ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: '2023,2022', } diff --git a/server-ce/test/templates.spec.ts b/server-ce/test/templates.spec.ts index bb581650a2..e36e99315d 100644 --- a/server-ce/test/templates.spec.ts +++ b/server-ce/test/templates.spec.ts @@ -96,12 +96,12 @@ describe('Templates', () => { .parent() .parent() .within(() => cy.get('input[type="checkbox"]').first().check()) - cy.get('.project-list-sidebar-react').within(() => { + cy.get('.project-list-sidebar-scroll').within(() => { cy.findAllByText('New Tag').first().click() }) cy.focused().type(tagName) cy.findByText('Create').click() - cy.get('.project-list-sidebar-react').within(() => { + cy.get('.project-list-sidebar-scroll').within(() => { cy.findByText(tagName) .parent() .within(() => cy.get('.name').should('have.text', `${tagName} (1)`)) diff --git a/services/chat/buildscript.txt b/services/chat/buildscript.txt index e8020de0d4..1dc88e9fa6 100644 --- a/services/chat/buildscript.txt +++ b/services/chat/buildscript.txt @@ -6,4 +6,4 @@ chat --esmock-loader=False --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/services/clsi/README.md b/services/clsi/README.md index 33a9c95c1c..16e40b8990 100644 --- a/services/clsi/README.md +++ b/services/clsi/README.md @@ -20,6 +20,7 @@ The CLSI can be configured through the following environment variables: * `CATCH_ERRORS` - Set to `true` to log uncaught exceptions * `COMPILE_GROUP_DOCKER_CONFIGS` - JSON string of Docker configs for compile groups * `COMPILES_HOST_DIR` - Working directory for LaTeX compiles +* `OUTPUT_HOST_DIR` - Output directory for LaTeX compiles * `COMPILE_SIZE_LIMIT` - Sets the body-parser [limit](https://github.com/expressjs/body-parser#limit) * `DOCKER_RUNNER` - Set to true to use sibling containers * `DOCKER_RUNTIME` - diff --git a/services/clsi/app.js b/services/clsi/app.js index 8715802a0e..8de9d89b9b 100644 --- a/services/clsi/app.js +++ b/services/clsi/app.js @@ -258,6 +258,8 @@ app.use(function (error, req, res, next) { if (error instanceof Errors.NotFoundError) { logger.debug({ err: error, url: req.url }, 'not found error') res.sendStatus(404) + } else if (error instanceof Errors.InvalidParameter) { + res.status(400).send(error.message) } else if (error.code === 'EPIPE') { // inspect container returns EPIPE when shutting down res.sendStatus(503) // send 503 Unavailable response diff --git a/services/clsi/app/js/CLSICacheHandler.js b/services/clsi/app/js/CLSICacheHandler.js new file mode 100644 index 0000000000..de6f512987 --- /dev/null +++ b/services/clsi/app/js/CLSICacheHandler.js @@ -0,0 +1,276 @@ +const crypto = require('node:crypto') +const fs = require('node:fs') +const Path = require('node:path') +const { pipeline } = require('node:stream/promises') +const { createGzip, createGunzip } = require('node:zlib') +const tarFs = require('tar-fs') +const _ = require('lodash') +const { + fetchNothing, + fetchStream, + RequestFailedError, +} = require('@overleaf/fetch-utils') +const logger = require('@overleaf/logger') +const Metrics = require('@overleaf/metrics') +const Settings = require('@overleaf/settings') +const { CACHE_SUBDIR } = require('./OutputCacheManager') +const { isExtraneousFile } = require('./ResourceWriter') + +const TIMING_BUCKETS = [ + 0, 10, 100, 1000, 2000, 5000, 10000, 15000, 20000, 30000, +] +const MAX_ENTRIES_IN_OUTPUT_TAR = 100 + +/** + * @param {string} projectId + * @param {string} userId + * @param {string} buildId + * @param {string} editorId + * @param {[{path: string}]} outputFiles + * @param {string} compileGroup + * @param {Record} options + */ +function notifyCLSICacheAboutBuild({ + projectId, + userId, + buildId, + editorId, + outputFiles, + compileGroup, + options, +}) { + if (!Settings.apis.clsiCache.enabled) return + + /** + * @param {[{path: string}]} files + */ + const enqueue = files => { + Metrics.count('clsi_cache_enqueue_files', files.length) + fetchNothing(`${Settings.apis.clsiCache.url}/enqueue`, { + method: 'POST', + json: { + projectId, + userId, + buildId, + editorId, + files, + downloadHost: Settings.apis.clsi.downloadHost, + clsiServerId: Settings.apis.clsi.clsiServerId, + compileGroup, + options, + }, + signal: AbortSignal.timeout(15_000), + }).catch(err => { + logger.warn( + { err, projectId, userId, buildId }, + 'enqueue for clsi cache failed' + ) + }) + } + + // PDF preview + enqueue( + outputFiles + .filter( + f => + f.path === 'output.pdf' || + f.path === 'output.log' || + f.path === 'output.synctex.gz' || + f.path.endsWith('.blg') + ) + .map(f => { + if (f.path === 'output.pdf') { + return _.pick(f, 'path', 'size', 'contentId', 'ranges') + } + return _.pick(f, 'path') + }) + ) + + // Compile Cache + buildTarball({ projectId, userId, buildId, outputFiles }) + .then(() => { + enqueue([{ path: 'output.tar.gz' }]) + }) + .catch(err => { + logger.warn( + { err, projectId, userId, buildId }, + 'build output.tar.gz for clsi cache failed' + ) + }) +} + +/** + * @param {string} projectId + * @param {string} userId + * @param {string} buildId + * @param {[{path: string}]} outputFiles + * @return {Promise} + */ +async function buildTarball({ projectId, userId, buildId, outputFiles }) { + const timer = new Metrics.Timer('clsi_cache_build', 1, {}, TIMING_BUCKETS) + const outputDir = Path.join( + Settings.path.outputDir, + userId ? `${projectId}-${userId}` : projectId, + CACHE_SUBDIR, + buildId + ) + + const files = outputFiles.filter(f => !isExtraneousFile(f.path)) + if (files.length > MAX_ENTRIES_IN_OUTPUT_TAR) { + Metrics.inc('clsi_cache_build_too_many_entries') + throw new Error('too many output files for output.tar.gz') + } + Metrics.count('clsi_cache_build_files', files.length) + + const path = Path.join(outputDir, 'output.tar.gz') + try { + await pipeline( + tarFs.pack(outputDir, { entries: files.map(f => f.path) }), + createGzip(), + fs.createWriteStream(path) + ) + } catch (err) { + try { + await fs.promises.unlink(path) + } catch (e) {} + throw err + } finally { + timer.done() + } +} + +/** + * @param {string} projectId + * @param {string} userId + * @param {string} editorId + * @param {string} buildId + * @param {string} outputDir + * @return {Promise} + */ +async function downloadOutputDotSynctexFromCompileCache( + projectId, + userId, + editorId, + buildId, + outputDir +) { + if (!Settings.apis.clsiCache.enabled) return false + + const timer = new Metrics.Timer( + 'clsi_cache_download', + 1, + { method: 'synctex' }, + TIMING_BUCKETS + ) + let stream + try { + stream = await fetchStream( + `${Settings.apis.clsiCache.url}/project/${projectId}/${ + userId ? `user/${userId}/` : '' + }build/${editorId}-${buildId}/search/output/output.synctex.gz`, + { + method: 'GET', + signal: AbortSignal.timeout(10_000), + } + ) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + timer.done({ status: 'not-found' }) + return false + } + timer.done({ status: 'error' }) + throw err + } + await fs.promises.mkdir(outputDir, { recursive: true }) + const dst = Path.join(outputDir, 'output.synctex.gz') + const tmp = dst + crypto.randomUUID() + try { + await pipeline(stream, fs.createWriteStream(tmp)) + await fs.promises.rename(tmp, dst) + } catch (err) { + try { + await fs.promises.unlink(tmp) + } catch {} + throw err + } + timer.done({ status: 'success' }) + return true +} + +/** + * @param {string} projectId + * @param {string} userId + * @param {string} compileDir + * @return {Promise} + */ +async function downloadLatestCompileCache(projectId, userId, compileDir) { + if (!Settings.apis.clsiCache.enabled) return false + + const url = `${Settings.apis.clsiCache.url}/project/${projectId}/${ + userId ? `user/${userId}/` : '' + }latest/output/output.tar.gz` + const timer = new Metrics.Timer( + 'clsi_cache_download', + 1, + { method: 'tar' }, + TIMING_BUCKETS + ) + let stream + try { + stream = await fetchStream(url, { + method: 'GET', + signal: AbortSignal.timeout(10_000), + }) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + timer.done({ status: 'not-found' }) + return false + } + timer.done({ status: 'error' }) + throw err + } + let n = 0 + let abort = false + await pipeline( + stream, + createGunzip(), + tarFs.extract(compileDir, { + // use ignore hook for counting entries (files+folders) and validation. + // Include folders as they incur mkdir calls. + ignore(_, header) { + if (abort) return true // log once + n++ + if (n > MAX_ENTRIES_IN_OUTPUT_TAR) { + abort = true + logger.warn( + { + url, + compileDir, + }, + 'too many entries in tar-ball from clsi-cache' + ) + } else if (header.type !== 'file' && header.type !== 'directory') { + abort = true + logger.warn( + { + url, + compileDir, + entryType: header.type, + }, + 'unexpected entry in tar-ball from clsi-cache' + ) + } + return abort + }, + }) + ) + Metrics.count('clsi_cache_download_entries', n) + timer.done({ status: 'success' }) + return !abort +} + +module.exports = { + notifyCLSICacheAboutBuild, + downloadLatestCompileCache, + downloadOutputDotSynctexFromCompileCache, +} diff --git a/services/clsi/app/js/CompileController.js b/services/clsi/app/js/CompileController.js index 8091055afe..87a7db6ec2 100644 --- a/services/clsi/app/js/CompileController.js +++ b/services/clsi/app/js/CompileController.js @@ -1,3 +1,4 @@ +const Path = require('node:path') const RequestParser = require('./RequestParser') const CompileManager = require('./CompileManager') const Settings = require('@overleaf/settings') @@ -5,6 +6,7 @@ const Metrics = require('./Metrics') const ProjectPersistenceManager = require('./ProjectPersistenceManager') const logger = require('@overleaf/logger') const Errors = require('./Errors') +const { notifyCLSICacheAboutBuild } = require('./CLSICacheHandler') let lastSuccessfulCompileTimestamp = 0 @@ -29,100 +31,133 @@ function compile(req, res, next) { if (error) { return next(error) } - CompileManager.doCompileWithLock(request, (error, result) => { - let { buildId, outputFiles, stats, timings } = result || {} - let code, status - if (outputFiles == null) { - outputFiles = [] - } - if (error instanceof Errors.AlreadyCompilingError) { - code = 423 // Http 423 Locked - status = 'compile-in-progress' - } else if (error instanceof Errors.FilesOutOfSyncError) { - code = 409 // Http 409 Conflict - status = 'retry' - logger.warn( - { + const stats = {} + const timings = {} + CompileManager.doCompileWithLock( + request, + stats, + timings, + (error, result) => { + let { buildId, outputFiles } = result || {} + let code, status + if (outputFiles == null) { + outputFiles = [] + } + if (error instanceof Errors.AlreadyCompilingError) { + code = 423 // Http 423 Locked + status = 'compile-in-progress' + } else if (error instanceof Errors.FilesOutOfSyncError) { + code = 409 // Http 409 Conflict + status = 'retry' + logger.warn( + { + projectId: request.project_id, + userId: request.user_id, + }, + 'files out of sync, please retry' + ) + } else if ( + error?.code === 'EPIPE' || + error instanceof Errors.TooManyCompileRequestsError + ) { + // docker returns EPIPE when shutting down + code = 503 // send 503 Unavailable response + status = 'unavailable' + } else if (error?.terminated) { + status = 'terminated' + } else if (error?.validate) { + status = `validation-${error.validate}` + } else if (error?.timedout) { + status = 'timedout' + logger.debug( + { err: error, projectId: request.project_id }, + 'timeout running compile' + ) + } else if (error) { + status = 'error' + code = 500 + logger.error( + { err: error, projectId: request.project_id }, + 'error running compile' + ) + } else { + if ( + outputFiles.some( + file => file.path === 'output.pdf' && file.size > 0 + ) + ) { + status = 'success' + lastSuccessfulCompileTimestamp = Date.now() + } else if (request.stopOnFirstError) { + status = 'stopped-on-first-error' + } else { + status = 'failure' + logger.warn( + { projectId: request.project_id, outputFiles }, + 'project failed to compile successfully, no output.pdf generated' + ) + } + + // log an error if any core files are found + if (outputFiles.some(file => file.path === 'core')) { + logger.error( + { projectId: request.project_id, req, outputFiles }, + 'core file found in output' + ) + } + } + + if (error) { + outputFiles = error.outputFiles || [] + buildId = error.buildId + } + + if ( + status === 'success' && + request.editorId && + request.populateClsiCache + ) { + notifyCLSICacheAboutBuild({ projectId: request.project_id, userId: request.user_id, + buildId: outputFiles[0].build, + editorId: request.editorId, + outputFiles, + compileGroup: request.compileGroup, + options: { + compiler: request.compiler, + draft: request.draft, + imageName: request.imageName + ? Path.basename(request.imageName) + : undefined, + rootResourcePath: request.rootResourcePath, + stopOnFirstError: request.stopOnFirstError, + }, + }) + } + + timer.done() + res.status(code || 200).send({ + compile: { + status, + error: error?.message || error, + stats, + timings, + buildId, + outputUrlPrefix: Settings.apis.clsi.outputUrlPrefix, + outputFiles: outputFiles.map(file => ({ + url: + `${Settings.apis.clsi.url}/project/${request.project_id}` + + (request.user_id != null + ? `/user/${request.user_id}` + : '') + + `/build/${file.build}/output/${file.path}`, + ...file, + })), }, - 'files out of sync, please retry' - ) - } else if ( - error?.code === 'EPIPE' || - error instanceof Errors.TooManyCompileRequestsError - ) { - // docker returns EPIPE when shutting down - code = 503 // send 503 Unavailable response - status = 'unavailable' - } else if (error?.terminated) { - status = 'terminated' - } else if (error?.validate) { - status = `validation-${error.validate}` - } else if (error?.timedout) { - status = 'timedout' - logger.debug( - { err: error, projectId: request.project_id }, - 'timeout running compile' - ) - } else if (error) { - status = 'error' - code = 500 - logger.error( - { err: error, projectId: request.project_id }, - 'error running compile' - ) - } else { - if ( - outputFiles.some( - file => file.path === 'output.pdf' && file.size > 0 - ) - ) { - status = 'success' - lastSuccessfulCompileTimestamp = Date.now() - } else if (request.stopOnFirstError) { - status = 'stopped-on-first-error' - } else { - status = 'failure' - logger.warn( - { projectId: request.project_id, outputFiles }, - 'project failed to compile successfully, no output.pdf generated' - ) - } - - // log an error if any core files are found - if (outputFiles.some(file => file.path === 'core')) { - logger.error( - { projectId: request.project_id, req, outputFiles }, - 'core file found in output' - ) - } + }) } - - if (error) { - outputFiles = error.outputFiles || [] - buildId = error.buildId - } - - timer.done() - res.status(code || 200).send({ - compile: { - status, - error: error?.message || error, - stats, - timings, - buildId, - outputUrlPrefix: Settings.apis.clsi.outputUrlPrefix, - outputFiles: outputFiles.map(file => ({ - url: - `${Settings.apis.clsi.url}/project/${request.project_id}` + - (request.user_id != null ? `/user/${request.user_id}` : '') + - `/build/${file.build}/output/${file.path}`, - ...file, - })), - }, - }) - }) + ) } ) }) @@ -153,24 +188,19 @@ function clearCache(req, res, next) { } function syncFromCode(req, res, next) { - const { file } = req.query + const { file, editorId, buildId, compileFromClsiCache } = req.query const line = parseInt(req.query.line, 10) const column = parseInt(req.query.column, 10) const { imageName } = req.query const projectId = req.params.project_id const userId = req.params.user_id - - if (imageName && !_isImageNameAllowed(imageName)) { - return res.status(400).send('invalid image') - } - CompileManager.syncFromCode( projectId, userId, file, line, column, - imageName, + { imageName, editorId, buildId, compileFromClsiCache }, function (error, pdfPositions) { if (error) { return next(error) @@ -186,20 +216,16 @@ function syncFromPdf(req, res, next) { const page = parseInt(req.query.page, 10) const h = parseFloat(req.query.h) const v = parseFloat(req.query.v) - const { imageName } = req.query + const { imageName, editorId, buildId, compileFromClsiCache } = req.query const projectId = req.params.project_id const userId = req.params.user_id - - if (imageName && !_isImageNameAllowed(imageName)) { - return res.status(400).send('invalid image') - } CompileManager.syncFromPdf( projectId, userId, page, h, v, - imageName, + { imageName, editorId, buildId, compileFromClsiCache }, function (error, codePositions) { if (error) { return next(error) @@ -216,9 +242,6 @@ function wordcount(req, res, next) { const projectId = req.params.project_id const userId = req.params.user_id const { image } = req.query - if (image && !_isImageNameAllowed(image)) { - return res.status(400).send('invalid image') - } logger.debug({ image, file, projectId }, 'word count request') CompileManager.wordcount( @@ -241,12 +264,6 @@ function status(req, res, next) { res.send('OK') } -function _isImageNameAllowed(imageName) { - const ALLOWED_IMAGES = - Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.allowedImages - return !ALLOWED_IMAGES || ALLOWED_IMAGES.includes(imageName) -} - module.exports = { compile, stopCompile, diff --git a/services/clsi/app/js/CompileManager.js b/services/clsi/app/js/CompileManager.js index a98b138d0a..b65fb3cd02 100644 --- a/services/clsi/app/js/CompileManager.js +++ b/services/clsi/app/js/CompileManager.js @@ -19,6 +19,10 @@ const Errors = require('./Errors') const CommandRunner = require('./CommandRunner') const { emitPdfStats } = require('./ContentCacheMetrics') const SynctexOutputParser = require('./SynctexOutputParser') +const { + downloadLatestCompileCache, + downloadOutputDotSynctexFromCompileCache, +} = require('./CLSICacheHandler') const COMPILE_TIME_BUCKETS = [ // NOTE: These buckets are locked in per metric name. @@ -42,22 +46,22 @@ function getOutputDir(projectId, userId) { return Path.join(Settings.path.outputDir, getCompileName(projectId, userId)) } -async function doCompileWithLock(request) { +async function doCompileWithLock(request, stats, timings) { const compileDir = getCompileDir(request.project_id, request.user_id) - await fsPromises.mkdir(compileDir, { recursive: true }) + request.isInitialCompile = + (await fsPromises.mkdir(compileDir, { recursive: true })) === compileDir // prevent simultaneous compiles const lock = LockManager.acquire(compileDir) try { - return await doCompile(request) + return await doCompile(request, stats, timings) } finally { lock.release() } } -async function doCompile(request) { +async function doCompile(request, stats, timings) { + const { project_id: projectId, user_id: userId } = request const compileDir = getCompileDir(request.project_id, request.user_id) - const stats = {} - const timings = {} const timerE2E = new Metrics.Timer( 'compile-e2e-v2', @@ -65,6 +69,25 @@ async function doCompile(request) { request.metricsOpts, COMPILE_TIME_BUCKETS ) + if (request.isInitialCompile) { + stats.isInitialCompile = 1 + request.metricsOpts.compile = 'initial' + if (request.compileFromClsiCache) { + try { + if (await downloadLatestCompileCache(projectId, userId, compileDir)) { + stats.restoredClsiCache = 1 + request.metricsOpts.compile = 'from-clsi-cache' + } + } catch (err) { + logger.warn( + { err, projectId, userId }, + 'failed to populate compile dir from cache' + ) + } + } + } else { + request.metricsOpts.compile = 'recompile' + } const writeToDiskTimer = new Metrics.Timer( 'write-to-disk', 1, @@ -296,7 +319,7 @@ async function doCompile(request) { emitPdfStats(stats, timings, request) } - return { outputFiles, stats, timings, buildId } + return { outputFiles, buildId } } async function _saveOutputFiles({ @@ -408,14 +431,7 @@ async function _checkDirectory(compileDir) { return true } -async function syncFromCode( - projectId, - userId, - filename, - line, - column, - imageName -) { +async function syncFromCode(projectId, userId, filename, line, column, opts) { // If LaTeX was run in a virtual environment, the file path that synctex expects // might not match the file path on the host. The .synctex.gz file however, will be accessed // wherever it is on the host. @@ -431,7 +447,7 @@ async function syncFromCode( '-o', outputFilePath, ] - const stdout = await _runSynctex(projectId, userId, command, imageName) + const stdout = await _runSynctex(projectId, userId, command, opts) logger.debug( { projectId, userId, filename, line, column, command, stdout }, 'synctex code output' @@ -439,7 +455,7 @@ async function syncFromCode( return SynctexOutputParser.parseViewOutput(stdout) } -async function syncFromPdf(projectId, userId, page, h, v, imageName) { +async function syncFromPdf(projectId, userId, page, h, v, opts) { const compileName = getCompileName(projectId, userId) const baseDir = Settings.path.synctexBaseDir(compileName) const outputFilePath = `${baseDir}/output.pdf` @@ -449,7 +465,7 @@ async function syncFromPdf(projectId, userId, page, h, v, imageName) { '-o', `${page}:${h}:${v}:${outputFilePath}`, ] - const stdout = await _runSynctex(projectId, userId, command, imageName) + const stdout = await _runSynctex(projectId, userId, command, opts) logger.debug({ projectId, userId, page, h, v, stdout }, 'synctex pdf output') return SynctexOutputParser.parseEditOutput(stdout, baseDir) } @@ -478,32 +494,85 @@ async function _checkFileExists(dir, filename) { } } -async function _runSynctex(projectId, userId, command, imageName) { - const directory = getCompileDir(projectId, userId) +async function _runSynctex(projectId, userId, command, opts) { + const { imageName, editorId, buildId, compileFromClsiCache } = opts + + if (imageName && !_isImageNameAllowed(imageName)) { + throw new Errors.InvalidParameter('invalid image') + } + if (editorId && !/^[a-f0-9-]+$/.test(editorId)) { + throw new Errors.InvalidParameter('invalid editorId') + } + if (buildId && !OutputCacheManager.BUILD_REGEX.test(buildId)) { + throw new Errors.InvalidParameter('invalid buildId') + } + + const outputDir = getOutputDir(projectId, userId) + const runInOutputDir = buildId && CommandRunner.canRunSyncTeXInOutputDir() + + const directory = runInOutputDir + ? Path.join(outputDir, OutputCacheManager.CACHE_SUBDIR, buildId) + : getCompileDir(projectId, userId) const timeout = 60 * 1000 // increased to allow for large projects const compileName = getCompileName(projectId, userId) - const compileGroup = 'synctex' + const compileGroup = runInOutputDir ? 'synctex-output' : 'synctex' const defaultImageName = Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.image - await _checkFileExists(directory, 'output.synctex.gz') - try { - const output = await CommandRunner.promises.run( - compileName, - command, - directory, - imageName || defaultImageName, - timeout, - {}, - compileGroup - ) - return output.stdout - } catch (error) { - throw OError.tag(error, 'error running synctex', { - command, - projectId, - userId, - }) - } + // eslint-disable-next-line @typescript-eslint/return-await + return await OutputCacheManager.promises.queueDirOperation( + outputDir, + /** + * @return {Promise} + */ + async () => { + try { + await _checkFileExists(directory, 'output.synctex.gz') + } catch (err) { + if ( + err instanceof Errors.NotFoundError && + compileFromClsiCache && + editorId && + buildId + ) { + try { + await downloadOutputDotSynctexFromCompileCache( + projectId, + userId, + editorId, + buildId, + directory + ) + } catch (err) { + logger.warn( + { err, projectId, userId, editorId, buildId }, + 'failed to download output.synctex.gz from clsi-cache' + ) + } + await _checkFileExists(directory, 'output.synctex.gz') + } else { + throw err + } + } + try { + const output = await CommandRunner.promises.run( + compileName, + command, + directory, + imageName || defaultImageName, + timeout, + {}, + compileGroup + ) + return output.stdout + } catch (error) { + throw OError.tag(error, 'error running synctex', { + command, + projectId, + userId, + }) + } + } + ) } async function wordcount(projectId, userId, filename, image) { @@ -515,6 +584,10 @@ async function wordcount(projectId, userId, filename, image) { const compileName = getCompileName(projectId, userId) const compileGroup = 'wordcount' + if (image && !_isImageNameAllowed(image)) { + throw new Errors.InvalidParameter('invalid image') + } + try { await fsPromises.mkdir(compileDir, { recursive: true }) } catch (err) { @@ -602,6 +675,12 @@ function _parseWordcountFromOutput(output) { return results } +function _isImageNameAllowed(imageName) { + const ALLOWED_IMAGES = + Settings.clsi && Settings.clsi.docker && Settings.clsi.docker.allowedImages + return !ALLOWED_IMAGES || ALLOWED_IMAGES.includes(imageName) +} + module.exports = { doCompileWithLock: callbackify(doCompileWithLock), stopCompile: callbackify(stopCompile), diff --git a/services/clsi/app/js/DockerRunner.js b/services/clsi/app/js/DockerRunner.js index 7aac613db4..def02eaf5b 100644 --- a/services/clsi/app/js/DockerRunner.js +++ b/services/clsi/app/js/DockerRunner.js @@ -6,21 +6,12 @@ const dockerode = new Docker() const crypto = require('node:crypto') const async = require('async') const LockManager = require('./DockerLockManager') -const fs = require('node:fs') const Path = require('node:path') const _ = require('lodash') const ONE_HOUR_IN_MS = 60 * 60 * 1000 logger.debug('using docker runner') -function usingSiblingContainers() { - return ( - Settings != null && - Settings.path != null && - Settings.path.sandboxedCompilesHostDir != null - ) -} - let containerMonitorTimeout let containerMonitorInterval @@ -35,24 +26,6 @@ const DockerRunner = { compileGroup, callback ) { - if (usingSiblingContainers()) { - const _newPath = Settings.path.sandboxedCompilesHostDir - logger.debug( - { path: _newPath }, - 'altering bind path for sibling containers' - ) - // Server Pro, example: - // '/var/lib/overleaf/data/compiles/' - // ... becomes ... - // '/opt/overleaf_data/data/compiles/' - directory = Path.join( - Settings.path.sandboxedCompilesHostDir, - Path.basename(directory) - ) - } - - const volumes = { [directory]: '/compile' } - command = command.map(arg => arg.toString().replace('$COMPILE_DIR', '/compile') ) @@ -72,7 +45,32 @@ const DockerRunner = { image = `${Settings.texliveImageNameOveride}/${img[2]}` } - if (compileGroup === 'synctex' || compileGroup === 'wordcount') { + if (compileGroup === 'synctex-output') { + // In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId' + // directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId' + // sandboxedCompilesHostDirOutput = '/host/output' + // Out: directory = '/host/output/projectId-userId/generated-files/buildId' + directory = Path.join( + Settings.path.sandboxedCompilesHostDirOutput, + ...directory.split('/').slice(-3) + ) + } else { + // In: directory = '/overleaf/services/clsi/compiles/projectId-userId' + // Path.basename(directory) === 'projectId-userId' + // sandboxedCompilesHostDirCompiles = '/host/compiles' + // Out: directory = '/host/compiles/projectId-userId' + directory = Path.join( + Settings.path.sandboxedCompilesHostDirCompiles, + Path.basename(directory) + ) + } + + const volumes = { [directory]: '/compile' } + if ( + compileGroup === 'synctex' || + compileGroup === 'synctex-output' || + compileGroup === 'wordcount' + ) { volumes[directory] += ':ro' } @@ -309,50 +307,17 @@ const DockerRunner = { LockManager.runWithLock( options.name, releaseLock => - // Check that volumes exist before starting the container. - // When a container is started with volume pointing to a - // non-existent directory then docker creates the directory but - // with root ownership. - DockerRunner._checkVolumes(options, volumes, err => { - if (err != null) { - return releaseLock(err) - } - DockerRunner._startContainer( - options, - volumes, - attachStreamHandler, - releaseLock - ) - }), - + DockerRunner._startContainer( + options, + volumes, + attachStreamHandler, + releaseLock + ), callback ) }, // Check that volumes exist and are directories - _checkVolumes(options, volumes, callback) { - if (usingSiblingContainers()) { - // Server Pro, with sibling-containers active, skip checks - return callback(null) - } - - const checkVolume = (path, cb) => - fs.stat(path, (err, stats) => { - if (err != null) { - return cb(err) - } - if (!stats.isDirectory()) { - return cb(new Error('not a directory')) - } - cb() - }) - const jobs = [] - for (const vol in volumes) { - jobs.push(cb => checkVolume(vol, cb)) - } - async.series(jobs, callback) - }, - _startContainer(options, volumes, attachStreamHandler, callback) { callback = _.once(callback) const { name } = options @@ -617,6 +582,10 @@ const DockerRunner = { containerMonitorInterval = undefined } }, + + canRunSyncTeXInOutputDir() { + return Boolean(Settings.path.sandboxedCompilesHostDirOutput) + }, } DockerRunner.startContainerMonitor() diff --git a/services/clsi/app/js/Errors.js b/services/clsi/app/js/Errors.js index 5c5fd3745a..64c3c7b59a 100644 --- a/services/clsi/app/js/Errors.js +++ b/services/clsi/app/js/Errors.js @@ -35,6 +35,7 @@ class QueueLimitReachedError extends OError {} class TimedOutError extends OError {} class NoXrefTableError extends OError {} class TooManyCompileRequestsError extends OError {} +class InvalidParameter extends OError {} module.exports = Errors = { QueueLimitReachedError, @@ -44,4 +45,5 @@ module.exports = Errors = { AlreadyCompilingError, NoXrefTableError, TooManyCompileRequestsError, + InvalidParameter, } diff --git a/services/clsi/app/js/LocalCommandRunner.js b/services/clsi/app/js/LocalCommandRunner.js index bac7d39400..ce27473358 100644 --- a/services/clsi/app/js/LocalCommandRunner.js +++ b/services/clsi/app/js/LocalCommandRunner.js @@ -99,6 +99,10 @@ module.exports = CommandRunner = { } return callback() }, + + canRunSyncTeXInOutputDir() { + return true + }, } module.exports.promises = { diff --git a/services/clsi/app/js/OutputCacheManager.js b/services/clsi/app/js/OutputCacheManager.js index 1e9a10c921..a1a0a89aa7 100644 --- a/services/clsi/app/js/OutputCacheManager.js +++ b/services/clsi/app/js/OutputCacheManager.js @@ -83,6 +83,13 @@ async function cleanupDirectory(dir, options) { }) } +/** + * @template T + * + * @param {string} dir + * @param {() => Promise} fn + * @return {Promise} + */ async function queueDirOperation(dir, fn) { const pending = PENDING_PROJECT_ACTIONS.get(dir) || Promise.resolve() const p = pending.then(fn, fn).finally(() => { @@ -677,4 +684,5 @@ OutputCacheManager.promises = { saveOutputFilesInBuildDir: promisify( OutputCacheManager.saveOutputFilesInBuildDir ), + queueDirOperation, } diff --git a/services/clsi/app/js/OutputFileArchiveManager.js b/services/clsi/app/js/OutputFileArchiveManager.js index a64d634e12..64c5198392 100644 --- a/services/clsi/app/js/OutputFileArchiveManager.js +++ b/services/clsi/app/js/OutputFileArchiveManager.js @@ -93,8 +93,11 @@ module.exports = { ) return outputFiles.filter( - // Ignore the pdf and also ignore the files ignored by the frontend. - ({ path }) => path !== 'output.pdf' && !ignoreFiles.includes(path) + // Ignore the pdf, clsi-cache tar-ball and also ignore the files ignored by the frontend. + ({ path }) => + path !== 'output.pdf' && + path !== 'output.tar.gz' && + !ignoreFiles.includes(path) ) } catch (error) { if ( diff --git a/services/clsi/app/js/ProjectPersistenceManager.js b/services/clsi/app/js/ProjectPersistenceManager.js index 7d4f071d2c..e96a4591c3 100644 --- a/services/clsi/app/js/ProjectPersistenceManager.js +++ b/services/clsi/app/js/ProjectPersistenceManager.js @@ -15,7 +15,6 @@ const logger = require('@overleaf/logger') const oneDay = 24 * 60 * 60 * 1000 const Metrics = require('@overleaf/metrics') const Settings = require('@overleaf/settings') -const diskusage = require('diskusage') const { callbackify } = require('node:util') const Path = require('node:path') const fs = require('node:fs') @@ -33,7 +32,13 @@ async function collectDiskStats() { const diskStats = {} for (const path of paths) { try { - const stats = await diskusage.check(path) + const { blocks, bavail, bsize } = await fs.promises.statfs(path) + const stats = { + // Warning: these values will be wrong by a factor in Docker-for-Mac. + // See https://github.com/docker/for-mac/issues/2136 + total: blocks * bsize, // Total size of the file system in bytes + available: bavail * bsize, // Free space available to unprivileged users. + } const diskAvailablePercent = (stats.available / stats.total) * 100 Metrics.gauge('disk_available_percent', diskAvailablePercent, 1, { path, diff --git a/services/clsi/app/js/RequestParser.js b/services/clsi/app/js/RequestParser.js index f5c07d3bcf..4e9d722921 100644 --- a/services/clsi/app/js/RequestParser.js +++ b/services/clsi/app/js/RequestParser.js @@ -3,6 +3,7 @@ const OutputCacheManager = require('./OutputCacheManager') const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex'] const MAX_TIMEOUT = 600 +const EDITOR_ID_REGEX = /^[a-f0-9-]{36}$/ // UUID function parse(body, callback) { const response = {} @@ -28,12 +29,24 @@ function parse(body, callback) { default: '', type: 'string', }), + // Will be populated later. Must always be populated for prom library. + compile: 'initial', } response.compiler = _parseAttribute('compiler', compile.options.compiler, { validValues: VALID_COMPILERS, default: 'pdflatex', type: 'string', }) + response.compileFromClsiCache = _parseAttribute( + 'compileFromClsiCache', + compile.options.compileFromClsiCache, + { default: false, type: 'boolean' } + ) + response.populateClsiCache = _parseAttribute( + 'populateClsiCache', + compile.options.populateClsiCache, + { default: false, type: 'boolean' } + ) response.enablePdfCaching = _parseAttribute( 'enablePdfCaching', compile.options.enablePdfCaching, @@ -137,6 +150,10 @@ function parse(body, callback) { ) response.rootResourcePath = _checkPath(rootResourcePath) + response.editorId = _parseAttribute('editorId', compile.options.editorId, { + type: 'string', + regex: EDITOR_ID_REGEX, + }) response.buildId = _parseAttribute('buildId', compile.options.buildId, { type: 'string', regex: OutputCacheManager.BUILD_REGEX, diff --git a/services/clsi/app/js/ResourceWriter.js b/services/clsi/app/js/ResourceWriter.js index 1db1c2baac..bf88538746 100644 --- a/services/clsi/app/js/ResourceWriter.js +++ b/services/clsi/app/js/ResourceWriter.js @@ -262,6 +262,7 @@ module.exports = ResourceWriter = { shouldDelete = false } if ( + path === 'output.tar.gz' || path === 'output.synctex.gz' || path === 'output.pdfxref' || path === 'output.pdf' || diff --git a/services/clsi/buildscript.txt b/services/clsi/buildscript.txt index 756d5c3bb6..1834ac9648 100644 --- a/services/clsi/buildscript.txt +++ b/services/clsi/buildscript.txt @@ -2,10 +2,10 @@ clsi --data-dirs=cache,compiles,output --dependencies= --docker-repos=gcr.io/overleaf-ops,us-east1-docker.pkg.dev/overleaf-ops/ol-docker ---env-add=ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2017.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=gcr.io/overleaf-ops,TEXLIVE_IMAGE_USER="tex",DOCKER_RUNNER="true",COMPILES_HOST_DIR=$PWD/compiles +--env-add=ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2017.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=gcr.io/overleaf-ops,TEXLIVE_IMAGE_USER="tex",DOCKER_RUNNER="true",COMPILES_HOST_DIR=$PWD/compiles,OUTPUT_HOST_DIR=$PWD/output --env-pass-through= --esmock-loader=False --node-version=20.18.2 --public-repo=True ---script-version=4.5.0 +--script-version=4.7.0 --use-large-ci-runner=True diff --git a/services/clsi/config/settings.defaults.js b/services/clsi/config/settings.defaults.js index 0f4111dc62..6f16e01a89 100644 --- a/services/clsi/config/settings.defaults.js +++ b/services/clsi/config/settings.defaults.js @@ -1,10 +1,12 @@ const Path = require('node:path') const http = require('node:http') const https = require('node:https') +const os = require('node:os') http.globalAgent.keepAlive = false https.globalAgent.keepAlive = false const isPreEmptible = process.env.PREEMPTIBLE === 'TRUE' +const CLSI_SERVER_ID = os.hostname().replace('-ctr', '') module.exports = { compileSizeLimit: process.env.COMPILE_SIZE_LIMIT || '7mb', @@ -48,12 +50,20 @@ module.exports = { url: `http://${process.env.CLSI_HOST || '127.0.0.1'}:3013`, // External url prefix for output files, e.g. for requests via load-balancers. outputUrlPrefix: `${process.env.ZONE ? `/zone/${process.env.ZONE}` : ''}`, + clsiServerId: process.env.CLSI_SERVER_ID || CLSI_SERVER_ID, + + downloadHost: process.env.DOWNLOAD_HOST || 'http://localhost:3013', }, clsiPerf: { host: `${process.env.CLSI_PERF_HOST || '127.0.0.1'}:${ process.env.CLSI_PERF_PORT || '3043' }`, }, + clsiCache: { + enabled: !!process.env.CLSI_CACHE_HOST, + url: `http://${process.env.CLSI_CACHE_HOST}:3044`, + downloadURL: `http://${process.env.CLSI_CACHE_NGINX_HOST || process.env.CLSI_CACHE_HOST}:8080`, + }, }, smokeTest: process.env.SMOKE_TEST || false, @@ -88,14 +98,15 @@ if (process.env.ALLOWED_COMPILE_GROUPS) { } } -if (process.env.DOCKER_RUNNER) { - let seccompProfilePath +if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') { module.exports.clsi = { - dockerRunner: process.env.DOCKER_RUNNER === 'true', + dockerRunner: true, docker: { runtime: process.env.DOCKER_RUNTIME, image: - process.env.TEXLIVE_IMAGE || 'quay.io/sharelatex/texlive-full:2017.1', + process.env.TEXLIVE_IMAGE || + process.env.TEX_LIVE_DOCKER_IMAGE || + 'quay.io/sharelatex/texlive-full:2017.1', env: { HOME: '/tmp', CLSI: 1, @@ -121,6 +132,7 @@ if (process.env.DOCKER_RUNNER) { const defaultCompileGroupConfig = { wordcount: { 'HostConfig.AutoRemove': true }, synctex: { 'HostConfig.AutoRemove': true }, + 'synctex-output': { 'HostConfig.AutoRemove': true }, } module.exports.clsi.docker.compileGroupConfig = Object.assign( defaultCompileGroupConfig, @@ -131,6 +143,7 @@ if (process.env.DOCKER_RUNNER) { process.exit(1) } + let seccompProfilePath try { seccompProfilePath = Path.resolve(__dirname, '../seccomp/clsi-profile.json') module.exports.clsi.docker.seccomp_profile = JSON.stringify( @@ -165,5 +178,23 @@ if (process.env.DOCKER_RUNNER) { module.exports.path.synctexBaseDir = () => '/compile' - module.exports.path.sandboxedCompilesHostDir = process.env.COMPILES_HOST_DIR + module.exports.path.sandboxedCompilesHostDirCompiles = + process.env.SANDBOXED_COMPILES_HOST_DIR_COMPILES || + process.env.SANDBOXED_COMPILES_HOST_DIR || + process.env.COMPILES_HOST_DIR + if (!module.exports.path.sandboxedCompilesHostDirCompiles) { + throw new Error( + 'SANDBOXED_COMPILES enabled, but SANDBOXED_COMPILES_HOST_DIR_COMPILES not set' + ) + } + + module.exports.path.sandboxedCompilesHostDirOutput = + process.env.SANDBOXED_COMPILES_HOST_DIR_OUTPUT || + process.env.OUTPUT_HOST_DIR + if (!module.exports.path.sandboxedCompilesHostDirOutput) { + // TODO(das7pad): Enforce in a future major version of Server Pro. + // throw new Error( + // 'SANDBOXED_COMPILES enabled, but SANDBOXED_COMPILES_HOST_DIR_OUTPUT not set' + // ) + } } diff --git a/services/clsi/docker-compose.ci.yml b/services/clsi/docker-compose.ci.yml index 00f54c6e72..1754a3a916 100644 --- a/services/clsi/docker-compose.ci.yml +++ b/services/clsi/docker-compose.ci.yml @@ -31,6 +31,7 @@ services: TEXLIVE_IMAGE_USER: "tex" DOCKER_RUNNER: "true" COMPILES_HOST_DIR: $PWD/compiles + OUTPUT_HOST_DIR: $PWD/output volumes: - ./compiles:/overleaf/services/clsi/compiles - /var/run/docker.sock:/var/run/docker.sock diff --git a/services/clsi/docker-compose.yml b/services/clsi/docker-compose.yml index a525c6029e..3e70c256ea 100644 --- a/services/clsi/docker-compose.yml +++ b/services/clsi/docker-compose.yml @@ -49,5 +49,6 @@ services: TEXLIVE_IMAGE_USER: "tex" DOCKER_RUNNER: "true" COMPILES_HOST_DIR: $PWD/compiles + OUTPUT_HOST_DIR: $PWD/output command: npm run --silent test:acceptance diff --git a/services/clsi/package.json b/services/clsi/package.json index 3f05ab543d..86566e0f59 100644 --- a/services/clsi/package.json +++ b/services/clsi/package.json @@ -27,13 +27,13 @@ "async": "^3.2.5", "body-parser": "^1.20.3", "bunyan": "^1.8.15", - "diskusage": "^1.1.3", "dockerode": "^4.0.5", "express": "^4.21.2", "lodash": "^4.17.21", "p-limit": "^3.1.0", "request": "^2.88.2", "send": "^0.19.0", + "tar-fs": "^3.0.4", "workerpool": "^6.1.5" }, "devDependencies": { diff --git a/services/clsi/test/acceptance/js/BrokenLatexFileTests.js b/services/clsi/test/acceptance/js/BrokenLatexFileTests.js index d22c142cff..46d07da092 100644 --- a/services/clsi/test/acceptance/js/BrokenLatexFileTests.js +++ b/services/clsi/test/acceptance/js/BrokenLatexFileTests.js @@ -11,6 +11,7 @@ const Client = require('./helpers/Client') const request = require('request') const ClsiApp = require('./helpers/ClsiApp') +const { expect } = require('chai') describe('Broken LaTeX file', function () { before(function (done) { @@ -62,6 +63,10 @@ Hello world return this.body.compile.status.should.equal('failure') }) + it('should return isInitialCompile flag', function () { + expect(this.body.compile.stats.isInitialCompile).to.equal(1) + }) + it('should return output files', function () { // NOTE: No output.pdf file. this.body.compile.outputFiles @@ -98,6 +103,10 @@ Hello world return this.body.compile.status.should.equal('failure') }) + it('should not return isInitialCompile flag', function () { + expect(this.body.compile.stats.isInitialCompile).to.not.exist + }) + it('should return output files', function () { // NOTE: No output.pdf file. this.body.compile.outputFiles diff --git a/services/clsi/test/acceptance/js/TimeoutTests.js b/services/clsi/test/acceptance/js/TimeoutTests.js index bca8ae71d2..e9175d223c 100644 --- a/services/clsi/test/acceptance/js/TimeoutTests.js +++ b/services/clsi/test/acceptance/js/TimeoutTests.js @@ -11,6 +11,7 @@ const Client = require('./helpers/Client') const request = require('request') const ClsiApp = require('./helpers/ClsiApp') +const { expect } = require('chai') describe('Timed out compile', function () { before(function (done) { @@ -54,6 +55,10 @@ describe('Timed out compile', function () { return this.body.compile.status.should.equal('timedout') }) + it('should return isInitialCompile flag', function () { + expect(this.body.compile.stats.isInitialCompile).to.equal(1) + }) + return it('should return the log output file name', function () { const outputFilePaths = this.body.compile.outputFiles.map(x => x.path) return outputFilePaths.should.include('output.log') diff --git a/services/clsi/test/setup.js b/services/clsi/test/setup.js index 19e1ae7165..b17507bf92 100644 --- a/services/clsi/test/setup.js +++ b/services/clsi/test/setup.js @@ -20,7 +20,7 @@ SandboxedModule.configure({ err() {}, }, }, - globals: { Buffer, console, process, URL }, + globals: { Buffer, console, process, URL, Math }, sourceTransformers: { removeNodePrefix: function (source) { return source.replace(/require\(['"]node:/g, "require('") diff --git a/services/clsi/test/unit/js/CompileControllerTests.js b/services/clsi/test/unit/js/CompileControllerTests.js index d97e433f29..e6d21aed9f 100644 --- a/services/clsi/test/unit/js/CompileControllerTests.js +++ b/services/clsi/test/unit/js/CompileControllerTests.js @@ -1,54 +1,11 @@ const SandboxedModule = require('sandboxed-module') const sinon = require('sinon') -const { expect } = require('chai') const modulePath = require('node:path').join( __dirname, '../../../app/js/CompileController' ) const Errors = require('../../../app/js/Errors') -function tryImageNameValidation(method, imageNameField) { - describe('when allowedImages is set', function () { - beforeEach(function () { - this.Settings.clsi = { docker: {} } - this.Settings.clsi.docker.allowedImages = [ - 'repo/image:tag1', - 'repo/image:tag2', - ] - this.res.send = sinon.stub() - this.res.status = sinon.stub().returns({ send: this.res.send }) - - this.CompileManager[method].reset() - }) - - describe('with an invalid image', function () { - beforeEach(function () { - this.req.query[imageNameField] = 'something/evil:1337' - this.CompileController[method](this.req, this.res, this.next) - }) - it('should return a 400', function () { - expect(this.res.status.calledWith(400)).to.equal(true) - }) - it('should not run the query', function () { - expect(this.CompileManager[method].called).to.equal(false) - }) - }) - - describe('with a valid image', function () { - beforeEach(function () { - this.req.query[imageNameField] = 'repo/image:tag1' - this.CompileController[method](this.req, this.res, this.next) - }) - it('should not return a 400', function () { - expect(this.res.status.calledWith(400)).to.equal(false) - }) - it('should run the query', function () { - expect(this.CompileManager[method].called).to.equal(true) - }) - }) - }) -} - describe('CompileController', function () { beforeEach(function () { this.buildId = 'build-id-123' @@ -61,6 +18,11 @@ describe('CompileController', function () { clsi: { url: 'http://clsi.example.com', outputUrlPrefix: '/zone/b', + downloadHost: 'http://localhost:3013', + }, + clsiCache: { + enabled: false, + url: 'http://localhost:3044', }, }, }), @@ -68,6 +30,11 @@ describe('CompileController', function () { Timer: sinon.stub().returns({ done: sinon.stub() }), }, './ProjectPersistenceManager': (this.ProjectPersistenceManager = {}), + './CLSICacheHandler': { + notifyCLSICacheAboutBuild: sinon.stub(), + downloadLatestCompileCache: sinon.stub().resolves(), + downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(), + }, './Errors': (this.Erros = Errors), }, }) @@ -113,16 +80,21 @@ describe('CompileController', function () { this.timings = { bar: 2 } this.res.status = sinon.stub().returnsThis() this.res.send = sinon.stub() + + this.CompileManager.doCompileWithLock = sinon + .stub() + .callsFake((_req, stats, timings, cb) => { + Object.assign(stats, this.stats) + Object.assign(timings, this.timings) + cb(null, { + outputFiles: this.output_files, + buildId: this.buildId, + }) + }) }) describe('successfully', function () { beforeEach(function () { - this.CompileManager.doCompileWithLock = sinon.stub().yields(null, { - outputFiles: this.output_files, - stats: this.stats, - timings: this.timings, - buildId: this.buildId, - }) this.CompileController.compile(this.req, this.res) }) @@ -166,12 +138,6 @@ describe('CompileController', function () { describe('without a outputUrlPrefix', function () { beforeEach(function () { this.Settings.apis.clsi.outputUrlPrefix = '' - this.CompileManager.doCompileWithLock = sinon.stub().yields(null, { - outputFiles: this.output_files, - stats: this.stats, - timings: this.timings, - buildId: this.buildId, - }) this.CompileController.compile(this.req, this.res) }) @@ -210,33 +176,35 @@ describe('CompileController', function () { build: 1234, }, ] - this.CompileManager.doCompileWithLock = sinon.stub().yields(null, { - outputFiles: this.output_files, - stats: this.stats, - timings: this.timings, - buildId: this.buildId, - }) + this.CompileManager.doCompileWithLock = sinon + .stub() + .callsFake((_req, stats, timings, cb) => { + Object.assign(stats, this.stats) + Object.assign(timings, this.timings) + cb(null, { + outputFiles: this.output_files, + buildId: this.buildId, + }) + }) this.CompileController.compile(this.req, this.res) }) it('should return the JSON response with status failure', function () { this.res.status.calledWith(200).should.equal(true) - this.res.send - .calledWith({ - compile: { - status: 'failure', - error: null, - stats: this.stats, - timings: this.timings, - outputUrlPrefix: '/zone/b', - buildId: this.buildId, - outputFiles: this.output_files.map(file => ({ - url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, - ...file, - })), - }, - }) - .should.equal(true) + this.res.send.should.have.been.calledWith({ + compile: { + status: 'failure', + error: null, + stats: this.stats, + timings: this.timings, + outputUrlPrefix: '/zone/b', + buildId: this.buildId, + outputFiles: this.output_files.map(file => ({ + url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, + ...file, + })), + }, + }) }) }) @@ -255,33 +223,35 @@ describe('CompileController', function () { build: 1234, }, ] - this.CompileManager.doCompileWithLock = sinon.stub().yields(null, { - outputFiles: this.output_files, - stats: this.stats, - timings: this.timings, - buildId: this.buildId, - }) + this.CompileManager.doCompileWithLock = sinon + .stub() + .callsFake((_req, stats, timings, cb) => { + Object.assign(stats, this.stats) + Object.assign(timings, this.timings) + cb(null, { + outputFiles: this.output_files, + buildId: this.buildId, + }) + }) this.CompileController.compile(this.req, this.res) }) it('should return the JSON response with status failure', function () { this.res.status.calledWith(200).should.equal(true) - this.res.send - .calledWith({ - compile: { - status: 'failure', - error: null, - stats: this.stats, - buildId: this.buildId, - timings: this.timings, - outputUrlPrefix: '/zone/b', - outputFiles: this.output_files.map(file => ({ - url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, - ...file, - })), - }, - }) - .should.equal(true) + this.res.send.should.have.been.calledWith({ + compile: { + status: 'failure', + error: null, + stats: this.stats, + buildId: this.buildId, + timings: this.timings, + outputUrlPrefix: '/zone/b', + outputFiles: this.output_files.map(file => ({ + url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, + ...file, + })), + }, + }) }) }) @@ -291,7 +261,11 @@ describe('CompileController', function () { error.buildId = this.buildId this.CompileManager.doCompileWithLock = sinon .stub() - .callsArgWith(1, error, null) + .callsFake((_req, stats, timings, cb) => { + Object.assign(stats, this.stats) + Object.assign(timings, this.timings) + cb(error) + }) this.CompileController.compile(this.req, this.res) }) @@ -305,9 +279,8 @@ describe('CompileController', function () { outputUrlPrefix: '/zone/b', outputFiles: [], buildId: this.buildId, - // JSON.stringify will omit these - stats: undefined, - timings: undefined, + stats: this.stats, + timings: this.timings, }, }) .should.equal(true) @@ -321,7 +294,11 @@ describe('CompileController', function () { ) this.CompileManager.doCompileWithLock = sinon .stub() - .callsArgWith(1, error, null) + .callsFake((_req, stats, timings, cb) => { + Object.assign(stats, this.stats) + Object.assign(timings, this.timings) + cb(error) + }) this.CompileController.compile(this.req, this.res) }) @@ -334,9 +311,10 @@ describe('CompileController', function () { error: 'too many concurrent compile requests', outputUrlPrefix: '/zone/b', outputFiles: [], + stats: this.stats, + timings: this.timings, + // JSON.stringify will omit these undefined values buildId: undefined, - stats: undefined, - timings: undefined, }, }) .should.equal(true) @@ -349,7 +327,11 @@ describe('CompileController', function () { this.error.timedout = true this.CompileManager.doCompileWithLock = sinon .stub() - .callsArgWith(1, this.error, null) + .callsFake((_req, stats, timings, cb) => { + Object.assign(stats, this.stats) + Object.assign(timings, this.timings) + cb(this.error) + }) this.CompileController.compile(this.req, this.res) }) @@ -362,10 +344,10 @@ describe('CompileController', function () { error: this.message, outputUrlPrefix: '/zone/b', outputFiles: [], - // JSON.stringify will omit these + stats: this.stats, + timings: this.timings, + // JSON.stringify will omit these undefined values buildId: undefined, - stats: undefined, - timings: undefined, }, }) .should.equal(true) @@ -376,7 +358,11 @@ describe('CompileController', function () { beforeEach(function () { this.CompileManager.doCompileWithLock = sinon .stub() - .callsArgWith(1, null, []) + .callsFake((_req, stats, timings, cb) => { + Object.assign(stats, this.stats) + Object.assign(timings, this.timings) + cb(null, {}) + }) this.CompileController.compile(this.req, this.res) }) @@ -389,10 +375,10 @@ describe('CompileController', function () { status: 'failure', outputUrlPrefix: '/zone/b', outputFiles: [], - // JSON.stringify will omit these + stats: this.stats, + timings: this.timings, + // JSON.stringify will omit these undefined values buildId: undefined, - stats: undefined, - timings: undefined, }, }) .should.equal(true) @@ -439,8 +425,6 @@ describe('CompileController', function () { }) .should.equal(true) }) - - tryImageNameValidation('syncFromCode', 'imageName') }) describe('syncFromPdf', function () { @@ -476,8 +460,6 @@ describe('CompileController', function () { }) .should.equal(true) }) - - tryImageNameValidation('syncFromPdf', 'imageName') }) describe('wordcount', function () { @@ -511,7 +493,5 @@ describe('CompileController', function () { }) .should.equal(true) }) - - tryImageNameValidation('wordcount', 'image') }) }) diff --git a/services/clsi/test/unit/js/CompileManagerTests.js b/services/clsi/test/unit/js/CompileManagerTests.js index 8d7aff4910..33a43ae029 100644 --- a/services/clsi/test/unit/js/CompileManagerTests.js +++ b/services/clsi/test/unit/js/CompileManagerTests.js @@ -62,6 +62,7 @@ describe('CompileManager', function () { } this.OutputCacheManager = { promises: { + queueDirOperation: sinon.stub().callsArg(1), saveOutputFiles: sinon .stub() .resolves({ outputFiles: this.buildFiles, buildId: this.buildId }), @@ -160,6 +161,11 @@ describe('CompileManager', function () { './LockManager': this.LockManager, './SynctexOutputParser': this.SynctexOutputParser, 'fs/promises': this.fsPromises, + './CLSICacheHandler': { + notifyCLSICacheAboutBuild: sinon.stub(), + downloadLatestCompileCache: sinon.stub().resolves(), + downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(), + }, }, }) }) @@ -177,6 +183,11 @@ describe('CompileManager', function () { flags: (this.flags = ['-file-line-error']), compileGroup: (this.compileGroup = 'compile-group'), stopOnFirstError: false, + metricsOpts: { + path: 'clsi-perf', + method: 'minimal', + compile: 'initial', + }, } this.env = { OVERLEAF_PROJECT_ID: this.projectId, @@ -188,7 +199,7 @@ describe('CompileManager', function () { const error = new Error('locked') this.LockManager.acquire.throws(error) await expect( - this.CompileManager.promises.doCompileWithLock(this.request) + this.CompileManager.promises.doCompileWithLock(this.request, {}, {}) ).to.be.rejectedWith(error) }) @@ -206,7 +217,9 @@ describe('CompileManager', function () { describe('normally', function () { beforeEach(async function () { this.result = await this.CompileManager.promises.doCompileWithLock( - this.request + this.request, + {}, + {} ) }) @@ -260,7 +273,11 @@ describe('CompileManager', function () { describe('with draft mode', function () { beforeEach(async function () { this.request.draft = true - await this.CompileManager.promises.doCompileWithLock(this.request) + await this.CompileManager.promises.doCompileWithLock( + this.request, + {}, + {} + ) }) it('should inject the draft mode header', function () { @@ -273,7 +290,11 @@ describe('CompileManager', function () { describe('with a check option', function () { beforeEach(async function () { this.request.check = 'error' - await this.CompileManager.promises.doCompileWithLock(this.request) + await this.CompileManager.promises.doCompileWithLock( + this.request, + {}, + {} + ) }) it('should run chktex', function () { @@ -305,7 +326,11 @@ describe('CompileManager', function () { beforeEach(async function () { this.request.rootResourcePath = 'main.Rtex' this.request.check = 'error' - await this.CompileManager.promises.doCompileWithLock(this.request) + await this.CompileManager.promises.doCompileWithLock( + this.request, + {}, + {} + ) }) it('should not run chktex', function () { @@ -334,7 +359,7 @@ describe('CompileManager', function () { error.timedout = true this.LatexRunner.promises.runLatex.rejects(error) await expect( - this.CompileManager.promises.doCompileWithLock(this.request) + this.CompileManager.promises.doCompileWithLock(this.request, {}, {}) ).to.be.rejected }) @@ -357,7 +382,7 @@ describe('CompileManager', function () { error.terminated = true this.LatexRunner.promises.runLatex.rejects(error) await expect( - this.CompileManager.promises.doCompileWithLock(this.request) + this.CompileManager.promises.doCompileWithLock(this.request, {}, {}) ).to.be.rejected }) @@ -455,7 +480,7 @@ describe('CompileManager', function () { this.filename, this.line, this.column, - customImageName + { imageName: customImageName } ) }) @@ -497,7 +522,7 @@ describe('CompileManager', function () { this.page, this.h, this.v, - '' + { imageName: '' } ) }) @@ -532,7 +557,7 @@ describe('CompileManager', function () { this.page, this.h, this.v, - customImageName + { imageName: customImageName } ) }) diff --git a/services/clsi/test/unit/js/DockerRunnerTests.js b/services/clsi/test/unit/js/DockerRunnerTests.js index 6c377d102b..d70aab52c7 100644 --- a/services/clsi/test/unit/js/DockerRunnerTests.js +++ b/services/clsi/test/unit/js/DockerRunnerTests.js @@ -76,8 +76,11 @@ describe('DockerRunner', function () { this.env = {} this.callback = sinon.stub() this.project_id = 'project-id-123' - this.volumes = { '/local/compile/directory': '/compile' } + this.volumes = { '/some/host/dir/compiles/directory': '/compile' } this.Settings.clsi.docker.image = this.defaultImage = 'default-image' + this.Settings.path.sandboxedCompilesHostDirCompiles = + '/some/host/dir/compiles' + this.Settings.path.sandboxedCompilesHostDirOutput = '/some/host/dir/output' this.compileGroup = 'compile-group' return (this.Settings.clsi.docker.env = { PATH: 'mock-path' }) }) @@ -151,9 +154,8 @@ describe('DockerRunner', function () { }) }) - describe('when path.sandboxedCompilesHostDir is set', function () { + describe('standard compile', function () { beforeEach(function () { - this.Settings.path.sandboxedCompilesHostDir = '/some/host/dir/compiles' this.directory = '/var/lib/overleaf/data/compiles/xyz' this.DockerRunner._runAndWaitForContainer = sinon .stub() @@ -183,6 +185,99 @@ describe('DockerRunner', function () { }) }) + describe('synctex-output', function () { + beforeEach(function () { + this.directory = '/var/lib/overleaf/data/output/xyz/generated-files/id' + this.DockerRunner._runAndWaitForContainer = sinon + .stub() + .callsArgWith(3, null, (this.output = 'mock-output')) + this.DockerRunner.run( + this.project_id, + this.command, + this.directory, + this.image, + this.timeout, + this.env, + 'synctex-output', + this.callback + ) + }) + + it('should re-write the bind directory and set ro flag', function () { + const volumes = + this.DockerRunner._runAndWaitForContainer.lastCall.args[1] + expect(volumes).to.deep.equal({ + '/some/host/dir/output/xyz/generated-files/id': '/compile:ro', + }) + }) + + it('should call the callback', function () { + this.callback.calledWith(null, this.output).should.equal(true) + }) + }) + + describe('synctex', function () { + beforeEach(function () { + this.directory = '/var/lib/overleaf/data/compile/xyz' + this.DockerRunner._runAndWaitForContainer = sinon + .stub() + .callsArgWith(3, null, (this.output = 'mock-output')) + this.DockerRunner.run( + this.project_id, + this.command, + this.directory, + this.image, + this.timeout, + this.env, + 'synctex', + this.callback + ) + }) + + it('should re-write the bind directory', function () { + const volumes = + this.DockerRunner._runAndWaitForContainer.lastCall.args[1] + expect(volumes).to.deep.equal({ + '/some/host/dir/compiles/xyz': '/compile:ro', + }) + }) + + it('should call the callback', function () { + this.callback.calledWith(null, this.output).should.equal(true) + }) + }) + + describe('wordcount', function () { + beforeEach(function () { + this.directory = '/var/lib/overleaf/data/compile/xyz' + this.DockerRunner._runAndWaitForContainer = sinon + .stub() + .callsArgWith(3, null, (this.output = 'mock-output')) + this.DockerRunner.run( + this.project_id, + this.command, + this.directory, + this.image, + this.timeout, + this.env, + 'wordcount', + this.callback + ) + }) + + it('should re-write the bind directory', function () { + const volumes = + this.DockerRunner._runAndWaitForContainer.lastCall.args[1] + expect(volumes).to.deep.equal({ + '/some/host/dir/compiles/xyz': '/compile:ro', + }) + }) + + it('should call the callback', function () { + this.callback.calledWith(null, this.output).should.equal(true) + }) + }) + describe('when the run throws an error', function () { beforeEach(function () { let firstTime = true @@ -390,7 +485,7 @@ describe('DockerRunner', function () { const options = this.DockerRunner._runAndWaitForContainer.lastCall.args[0] return expect(options.HostConfig).to.deep.include({ - Binds: ['/local/compile/directory:/compile:rw'], + Binds: ['/some/host/dir/compiles/directory:/compile:rw'], LogConfig: { Type: 'none', Config: {} }, CapDrop: 'ALL', SecurityOpt: ['no-new-privileges'], @@ -562,82 +657,6 @@ describe('DockerRunner', function () { }) }) - describe('when a volume does not exist', function () { - beforeEach(function () { - this.fs.stat = sinon.stub().yields(new Error('no such path')) - return this.DockerRunner.startContainer( - this.options, - this.volumes, - this.attachStreamHandler, - this.callback - ) - }) - - it('should not try to create the container', function () { - return this.createContainer.called.should.equal(false) - }) - - it('should call the callback with an error', function () { - this.callback.calledWith(sinon.match(Error)).should.equal(true) - }) - }) - - describe('when a volume exists but is not a directory', function () { - beforeEach(function () { - this.fs.stat = sinon.stub().yields(null, { - isDirectory() { - return false - }, - }) - return this.DockerRunner.startContainer( - this.options, - this.volumes, - this.attachStreamHandler, - this.callback - ) - }) - - it('should not try to create the container', function () { - return this.createContainer.called.should.equal(false) - }) - - it('should call the callback with an error', function () { - this.callback.calledWith(sinon.match(Error)).should.equal(true) - }) - }) - - describe('when a volume does not exist, but sibling-containers are used', function () { - beforeEach(function () { - this.fs.stat = sinon.stub().yields(new Error('no such path')) - this.Settings.path.sandboxedCompilesHostDir = '/some/path' - this.container.start = sinon.stub().yields() - return this.DockerRunner.startContainer( - this.options, - this.volumes, - () => {}, - this.callback - ) - }) - - afterEach(function () { - return delete this.Settings.path.sandboxedCompilesHostDir - }) - - it('should start the container with the given name', function () { - this.getContainer.calledWith(this.options.name).should.equal(true) - return this.container.start.called.should.equal(true) - }) - - it('should not try to create the container', function () { - return this.createContainer.called.should.equal(false) - }) - - return it('should call the callback', function () { - this.callback.called.should.equal(true) - return this.callback.calledWith(new Error()).should.equal(false) - }) - }) - return describe('when the container tries to be created, but already has been (race condition)', function () {}) }) diff --git a/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js b/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js index 2504d266ca..4f42411fba 100644 --- a/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js +++ b/services/clsi/test/unit/js/ProjectPersistenceManagerTests.js @@ -21,12 +21,16 @@ const tk = require('timekeeper') describe('ProjectPersistenceManager', function () { beforeEach(function () { + this.fsPromises = { + statfs: sinon.stub(), + } + this.ProjectPersistenceManager = SandboxedModule.require(modulePath, { requires: { '@overleaf/metrics': (this.Metrics = { gauge: sinon.stub() }), './UrlCache': (this.UrlCache = {}), './CompileManager': (this.CompileManager = {}), - diskusage: (this.diskusage = { check: sinon.stub() }), + fs: { promises: this.fsPromises }, '@overleaf/settings': (this.settings = { project_cache_length_ms: 1000, path: { @@ -44,9 +48,10 @@ describe('ProjectPersistenceManager', function () { describe('refreshExpiryTimeout', function () { it('should leave expiry alone if plenty of disk', function (done) { - this.diskusage.check.resolves({ - available: 40, - total: 100, + this.fsPromises.statfs.resolves({ + blocks: 100, + bsize: 1, + bavail: 40, }) this.ProjectPersistenceManager.refreshExpiryTimeout(() => { @@ -62,9 +67,10 @@ describe('ProjectPersistenceManager', function () { }) it('should drop EXPIRY_TIMEOUT 10% if low disk usage', function (done) { - this.diskusage.check.resolves({ - available: 5, - total: 100, + this.fsPromises.statfs.resolves({ + blocks: 100, + bsize: 1, + bavail: 5, }) this.ProjectPersistenceManager.refreshExpiryTimeout(() => { @@ -78,9 +84,10 @@ describe('ProjectPersistenceManager', function () { }) it('should not drop EXPIRY_TIMEOUT to below 50% of project_cache_length_ms', function (done) { - this.diskusage.check.resolves({ - available: 5, - total: 100, + this.fsPromises.statfs.resolves({ + blocks: 100, + bsize: 1, + bavail: 5, }) this.ProjectPersistenceManager.EXPIRY_TIMEOUT = 500 this.ProjectPersistenceManager.refreshExpiryTimeout(() => { @@ -94,7 +101,7 @@ describe('ProjectPersistenceManager', function () { }) it('should not modify EXPIRY_TIMEOUT if there is an error getting disk values', function (done) { - this.diskusage.check.throws(new Error()) + this.fsPromises.statfs.rejects(new Error()) this.ProjectPersistenceManager.refreshExpiryTimeout(() => { this.ProjectPersistenceManager.EXPIRY_TIMEOUT.should.equal(1000) done() diff --git a/services/contacts/buildscript.txt b/services/contacts/buildscript.txt index 0f4f2e643d..8563d1b71e 100644 --- a/services/contacts/buildscript.txt +++ b/services/contacts/buildscript.txt @@ -6,4 +6,4 @@ contacts --esmock-loader=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/services/docstore/buildscript.txt b/services/docstore/buildscript.txt index 0fb0267ca2..c329d7b571 100644 --- a/services/docstore/buildscript.txt +++ b/services/docstore/buildscript.txt @@ -6,4 +6,4 @@ docstore --esmock-loader=False --node-version=20.18.2 --public-repo=True ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/services/document-updater/app/js/DocumentManager.js b/services/document-updater/app/js/DocumentManager.js index 540a8a254c..dc20c27d7f 100644 --- a/services/document-updater/app/js/DocumentManager.js +++ b/services/document-updater/app/js/DocumentManager.js @@ -160,14 +160,6 @@ const DocumentManager = { alreadyLoaded, } = await DocumentManager.getDoc(projectId, docId) - if (oldLines != null && oldLines.length > 0 && oldLines[0].text != null) { - logger.debug( - { docId, projectId, oldLines, newLines }, - 'document is JSON so not updating' - ) - return - } - logger.debug( { docId, projectId, oldLines, newLines }, 'setting a document via http' diff --git a/services/document-updater/buildscript.txt b/services/document-updater/buildscript.txt index f303583bcb..ee013ebea9 100644 --- a/services/document-updater/buildscript.txt +++ b/services/document-updater/buildscript.txt @@ -6,4 +6,4 @@ document-updater --esmock-loader=False --node-version=20.18.2 --public-repo=True ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml index bdf10c9732..6deaad433d 100644 --- a/services/document-updater/docker-compose.ci.yml +++ b/services/document-updater/docker-compose.ci.yml @@ -21,6 +21,7 @@ services: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis QUEUES_REDIS_HOST: redis + HISTORY_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo POSTGRES_HOST: postgres diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml index 7dd27c9a39..e33174f9e2 100644 --- a/services/document-updater/docker-compose.yml +++ b/services/document-updater/docker-compose.yml @@ -30,6 +30,7 @@ services: environment: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis + HISTORY_REDIS_HOST: redis QUEUES_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo diff --git a/services/document-updater/scripts/flush_projects_with_no_history_id.js b/services/document-updater/scripts/flush_projects_with_no_history_id.js new file mode 100644 index 0000000000..aa912b4b66 --- /dev/null +++ b/services/document-updater/scripts/flush_projects_with_no_history_id.js @@ -0,0 +1,211 @@ +// @ts-check + +const Settings = require('@overleaf/settings') +const logger = require('@overleaf/logger') +const RedisManager = require('../app/js/RedisManager') +const minimist = require('minimist') +const { db, ObjectId } = require('../app/js/mongodb') +const ProjectManager = require('../app/js/ProjectManager') +const OError = require('@overleaf/o-error') + +const docUpdaterKeys = Settings.redis.documentupdater.key_schema + +const rclient = RedisManager.rclient + +const { verbose, commit, ...args } = minimist(process.argv.slice(2), { + boolean: ['verbose', 'commit'], + string: ['batchSize'], + default: { + batchSize: '1000', + }, +}) + +logger.logger.level(verbose ? 'debug' : 'warn') + +const batchSize = parseInt(args.batchSize, 10) + +/** + * @typedef {import('ioredis').Redis} Redis + */ + +/** + * + * @param {string} key + * @return {string|void} + */ +function extractDocId(key) { + const matches = key.match(/ProjectHistoryId:\{(.*?)\}/) + if (matches) { + return matches[1] + } +} + +/** + * + * @param {string} docId + * @return {Promise<{projectId: string, historyId: string}>} + */ +async function getHistoryId(docId) { + const doc = await db.docs.findOne( + { _id: new ObjectId(docId) }, + { projection: { project_id: 1 }, readPreference: 'secondaryPreferred' } + ) + + if (!doc) { + throw new OError('Doc not present in mongo', { docId }) + } + + const project = await db.projects.findOne( + { _id: doc.project_id }, + { + projection: { 'overleaf.history': 1 }, + readPreference: 'secondaryPreferred', + } + ) + + if (!project?.overleaf?.history?.id) { + throw new OError('Project not present in mongo (or has no history id)', { + docId, + project, + doc, + }) + } + + return { + historyId: project?.overleaf?.history?.id, + projectId: doc.project_id.toString(), + } +} + +/** + * @typedef {Object} UpdateableDoc + * @property {string} docId + * @property {string} projectId + * @property {string} historyId + */ + +/** + * + * @param {Redis} node + * @param {Array} docIds + * @return {Promise>} + */ +async function findDocsWithMissingHistoryIds(node, docIds) { + const historyIds = await node.mget( + docIds.map(docId => docUpdaterKeys.projectHistoryId({ doc_id: docId })) + ) + + const results = [] + + for (const index in docIds) { + const historyId = historyIds[index] + const docId = docIds[index] + if (!historyId) { + try { + const { projectId, historyId } = await getHistoryId(docId) + results.push({ projectId, historyId, docId }) + } catch (error) { + logger.warn( + { error }, + 'Error gathering data for doc with missing history id' + ) + } + } + } + return results +} + +/** + * + * @param {Array} updates + * @return {Promise} + */ +async function fixAndFlushProjects(updates) { + for (const update of updates) { + if (commit) { + try { + await rclient.set( + docUpdaterKeys.projectHistoryId({ doc_id: update.docId }), + update.historyId + ) + logger.debug({ ...update }, 'Set history id in redis') + await ProjectManager.promises.flushAndDeleteProjectWithLocks( + update.projectId, + {} + ) + logger.debug({ ...update }, 'Flushed project') + } catch (err) { + logger.error({ err, ...update }, 'Error fixing and flushing project') + } + } else { + logger.debug( + { ...update }, + 'Would have set history id in redis and flushed' + ) + } + } +} + +/** + * + * @param {Array} nodes + * @param {number} batchSize + * @return {Promise} + */ +async function scanNodes(nodes, batchSize = 1000) { + let scanned = 0 + + for (const node of nodes) { + const stream = node.scanStream({ + match: docUpdaterKeys.projectHistoryId({ doc_id: '*' }), + count: batchSize, + }) + + for await (const docKeys of stream) { + if (docKeys.length === 0) { + continue + } + stream.pause() + scanned += docKeys.length + + const docIds = docKeys + .map((/** @type {string} */ docKey) => extractDocId(docKey)) + .filter(Boolean) + + try { + const updates = await findDocsWithMissingHistoryIds(node, docIds) + if (updates.length > 0) { + logger.info({ updates }, 'Found doc(s) with missing history ids') + await fixAndFlushProjects(updates) + } + } catch (error) { + logger.error({ docKeys }, 'Error processing batch') + } finally { + stream.resume() + } + } + + logger.info({ scanned, server: node.serverInfo.role }, 'Scanned node') + } +} + +async function main({ batchSize }) { + const nodes = (typeof rclient.nodes === 'function' + ? rclient.nodes('master') + : undefined) || [rclient] + await scanNodes(nodes, batchSize) +} + +let code = 0 + +main({ batchSize }) + .then(() => { + logger.info({}, 'done') + }) + .catch(error => { + logger.error({ error }, 'error') + code = 1 + }) + .finally(() => { + rclient.quit().then(() => process.exit(code)) + }) diff --git a/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js b/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js index 73e22aace7..0df2e72a08 100644 --- a/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js +++ b/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js @@ -16,13 +16,16 @@ const DocUpdaterClient = require('./helpers/DocUpdaterClient') const DocUpdaterApp = require('./helpers/DocUpdaterApp') describe('Applying updates to a doc', function () { - before(function (done) { + beforeEach(function (done) { + sinon.spy(MockWebApi, 'getDocument') this.lines = ['one', 'two', 'three'] this.version = 42 this.op = { i: 'one and a half\n', p: 4, } + this.project_id = DocUpdaterClient.randomId() + this.doc_id = DocUpdaterClient.randomId() this.update = { doc: this.doc_id, op: [this.op], @@ -31,12 +34,12 @@ describe('Applying updates to a doc', function () { this.result = ['one', 'one and a half', 'two', 'three'] DocUpdaterApp.ensureRunning(done) }) + afterEach(function () { + sinon.restore() + }) describe('when the document is not loaded', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() - sinon.spy(MockWebApi, 'getDocument') + beforeEach(function (done) { this.startTime = Date.now() MockWebApi.insertDoc(this.project_id, this.doc_id, { lines: this.lines, @@ -50,15 +53,25 @@ describe('Applying updates to a doc', function () { if (error != null) { throw error } - setTimeout(done, 200) + setTimeout(() => { + rclientProjectHistory.get( + ProjectHistoryKeys.projectHistoryFirstOpTimestamp({ + project_id: this.project_id, + }), + (error, result) => { + if (error != null) { + throw error + } + result = parseInt(result, 10) + this.firstOpTimestamp = result + done() + } + ) + }, 200) } ) }) - after(function () { - MockWebApi.getDocument.restore() - }) - it('should load the document from the web API', function () { MockWebApi.getDocument .calledWith(this.project_id, this.doc_id) @@ -92,21 +105,8 @@ describe('Applying updates to a doc', function () { ) }) - it('should set the first op timestamp', function (done) { - rclientProjectHistory.get( - ProjectHistoryKeys.projectHistoryFirstOpTimestamp({ - project_id: this.project_id, - }), - (error, result) => { - if (error != null) { - throw error - } - result = parseInt(result, 10) - result.should.be.within(this.startTime, Date.now()) - this.firstOpTimestamp = result - done() - } - ) + it('should set the first op timestamp', function () { + this.firstOpTimestamp.should.be.within(this.startTime, Date.now()) }) it('should yield last updated time', function (done) { @@ -138,7 +138,7 @@ describe('Applying updates to a doc', function () { }) describe('when sending another update', function () { - before(function (done) { + beforeEach(function (done) { this.timeout(10000) this.second_update = Object.assign({}, this.update) this.second_update.v = this.version + 1 @@ -207,13 +207,85 @@ describe('Applying updates to a doc', function () { ) }) }) + + describe('when another client is sending a concurrent update', function () { + beforeEach(function (done) { + this.timeout(10000) + this.otherUpdate = { + doc: this.doc_id, + op: [{ p: 8, i: 'two and a half\n' }], + v: this.version, + meta: { source: 'other-random-publicId' }, + } + this.secondStartTime = Date.now() + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.otherUpdate, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }) + + it('should update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) done(error) + doc.lines.should.deep.equal([ + 'one', + 'one and a half', + 'two', + 'two and a half', + 'three', + ]) + done() + } + ) + }) + + it('should not change the first op timestamp', function (done) { + rclientProjectHistory.get( + ProjectHistoryKeys.projectHistoryFirstOpTimestamp({ + project_id: this.project_id, + }), + (error, result) => { + if (error != null) { + throw error + } + result = parseInt(result, 10) + result.should.equal(this.firstOpTimestamp) + done() + } + ) + }) + + it('should yield last updated time', function (done) { + DocUpdaterClient.getProjectLastUpdatedAt( + this.project_id, + (error, res, body) => { + if (error != null) { + throw error + } + res.statusCode.should.equal(200) + body.lastUpdatedAt.should.be.within( + this.secondStartTime, + Date.now() + ) + done() + } + ) + }) + }) }) describe('when the document is loaded', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() - + beforeEach(function (done) { MockWebApi.insertDoc(this.project_id, this.doc_id, { lines: this.lines, version: this.version, @@ -222,7 +294,7 @@ describe('Applying updates to a doc', function () { if (error != null) { throw error } - sinon.spy(MockWebApi, 'getDocument') + sinon.resetHistory() DocUpdaterClient.sendUpdate( this.project_id, this.doc_id, @@ -237,10 +309,6 @@ describe('Applying updates to a doc', function () { }) }) - after(function () { - MockWebApi.getDocument.restore() - }) - it('should not need to call the web api', function () { MockWebApi.getDocument.called.should.equal(false) }) @@ -272,10 +340,7 @@ describe('Applying updates to a doc', function () { }) describe('when the document is loaded and is using project-history only', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() - + beforeEach(function (done) { MockWebApi.insertDoc(this.project_id, this.doc_id, { lines: this.lines, version: this.version, @@ -284,7 +349,7 @@ describe('Applying updates to a doc', function () { if (error != null) { throw error } - sinon.spy(MockWebApi, 'getDocument') + sinon.resetHistory() DocUpdaterClient.sendUpdate( this.project_id, this.doc_id, @@ -299,10 +364,6 @@ describe('Applying updates to a doc', function () { }) }) - after(function () { - MockWebApi.getDocument.restore() - }) - it('should update the doc', function (done) { DocUpdaterClient.getDoc( this.project_id, @@ -331,9 +392,7 @@ describe('Applying updates to a doc', function () { describe('when the document has been deleted', function () { describe('when the ops come in a single linear order', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() + beforeEach(function (done) { const lines = ['', '', ''] MockWebApi.insertDoc(this.project_id, this.doc_id, { lines, @@ -353,54 +412,49 @@ describe('Applying updates to a doc', function () { { doc_id: this.doc_id, v: 10, op: [{ i: 'd', p: 10 }] }, ] this.my_result = ['hello world', '', ''] - done() - }) - - it('should be able to continue applying updates when the project has been deleted', function (done) { - let update const actions = [] - for (update of this.updates.slice(0, 6)) { - ;(update => { - actions.push(callback => - DocUpdaterClient.sendUpdate( - this.project_id, - this.doc_id, - update, - callback - ) + for (const update of this.updates.slice(0, 6)) { + actions.push(callback => + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + update, + callback ) - })(update) + ) } actions.push(callback => DocUpdaterClient.deleteDoc(this.project_id, this.doc_id, callback) ) - for (update of this.updates.slice(6)) { - ;(update => { - actions.push(callback => - DocUpdaterClient.sendUpdate( - this.project_id, - this.doc_id, - update, - callback - ) + for (const update of this.updates.slice(6)) { + actions.push(callback => + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + update, + callback ) - })(update) + ) } - async.series(actions, error => { - if (error != null) { - throw error + // process updates + actions.push(cb => + DocUpdaterClient.getDoc(this.project_id, this.doc_id, cb) + ) + + async.series(actions, done) + }) + + it('should be able to continue applying updates when the project has been deleted', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) return done(error) + doc.lines.should.deep.equal(this.my_result) + done() } - DocUpdaterClient.getDoc( - this.project_id, - this.doc_id, - (error, res, doc) => { - if (error) return done(error) - doc.lines.should.deep.equal(this.my_result) - done() - } - ) - }) + ) }) it('should store the doc ops in the correct order', function (done) { @@ -422,9 +476,7 @@ describe('Applying updates to a doc', function () { }) describe('when older ops come in after the delete', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() + beforeEach(function (done) { const lines = ['', '', ''] MockWebApi.insertDoc(this.project_id, this.doc_id, { lines, @@ -492,11 +544,9 @@ describe('Applying updates to a doc', function () { }) describe('with a broken update', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() + beforeEach(function (done) { this.broken_update = { - doc_id: this.doc_id, + doc: this.doc_id, v: this.version, op: [{ d: 'not the correct content', p: 0 }], } @@ -547,9 +597,7 @@ describe('Applying updates to a doc', function () { }) describe('when there is no version in Mongo', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() + beforeEach(function (done) { MockWebApi.insertDoc(this.project_id, this.doc_id, { lines: this.lines, }) @@ -586,9 +634,7 @@ describe('Applying updates to a doc', function () { }) describe('when the sending duplicate ops', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() + beforeEach(function (done) { MockWebApi.insertDoc(this.project_id, this.doc_id, { lines: this.lines, version: this.version, @@ -671,11 +717,9 @@ describe('Applying updates to a doc', function () { }) describe('when sending updates for a non-existing doc id', function () { - before(function (done) { - this.project_id = DocUpdaterClient.randomId() - this.doc_id = DocUpdaterClient.randomId() + beforeEach(function (done) { this.non_existing = { - doc_id: this.doc_id, + doc: this.doc_id, v: this.version, op: [{ d: 'content', p: 0 }], } diff --git a/services/filestore/app.js b/services/filestore/app.js index 23d01f1ca3..24741e079c 100644 --- a/services/filestore/app.js +++ b/services/filestore/app.js @@ -50,64 +50,68 @@ app.use((req, res, next) => { Metrics.injectMetricsRoute(app) -app.head( - '/project/:project_id/file/:file_id', - keyBuilder.userFileKeyMiddleware, - fileController.getFileHead -) -app.get( - '/project/:project_id/file/:file_id', - keyBuilder.userFileKeyMiddleware, - fileController.getFile -) -app.post( - '/project/:project_id/file/:file_id', - keyBuilder.userFileKeyMiddleware, - fileController.insertFile -) -app.put( - '/project/:project_id/file/:file_id', - keyBuilder.userFileKeyMiddleware, - bodyParser.json(), - fileController.copyFile -) -app.delete( - '/project/:project_id/file/:file_id', - keyBuilder.userFileKeyMiddleware, - fileController.deleteFile -) -app.delete( - '/project/:project_id', - keyBuilder.userProjectKeyMiddleware, - fileController.deleteProject -) +if (settings.filestore.stores.user_files) { + app.head( + '/project/:project_id/file/:file_id', + keyBuilder.userFileKeyMiddleware, + fileController.getFileHead + ) + app.get( + '/project/:project_id/file/:file_id', + keyBuilder.userFileKeyMiddleware, + fileController.getFile + ) + app.post( + '/project/:project_id/file/:file_id', + keyBuilder.userFileKeyMiddleware, + fileController.insertFile + ) + app.put( + '/project/:project_id/file/:file_id', + keyBuilder.userFileKeyMiddleware, + bodyParser.json(), + fileController.copyFile + ) + app.delete( + '/project/:project_id/file/:file_id', + keyBuilder.userFileKeyMiddleware, + fileController.deleteFile + ) + app.delete( + '/project/:project_id', + keyBuilder.userProjectKeyMiddleware, + fileController.deleteProject + ) -app.get( - '/project/:project_id/size', - keyBuilder.userProjectKeyMiddleware, - fileController.directorySize -) + app.get( + '/project/:project_id/size', + keyBuilder.userProjectKeyMiddleware, + fileController.directorySize + ) +} -app.head( - '/template/:template_id/v/:version/:format', - keyBuilder.templateFileKeyMiddleware, - fileController.getFileHead -) -app.get( - '/template/:template_id/v/:version/:format', - keyBuilder.templateFileKeyMiddleware, - fileController.getFile -) -app.get( - '/template/:template_id/v/:version/:format/:sub_type', - keyBuilder.templateFileKeyMiddleware, - fileController.getFile -) -app.post( - '/template/:template_id/v/:version/:format', - keyBuilder.templateFileKeyMiddleware, - fileController.insertFile -) +if (settings.filestore.stores.template_files) { + app.head( + '/template/:template_id/v/:version/:format', + keyBuilder.templateFileKeyMiddleware, + fileController.getFileHead + ) + app.get( + '/template/:template_id/v/:version/:format', + keyBuilder.templateFileKeyMiddleware, + fileController.getFile + ) + app.get( + '/template/:template_id/v/:version/:format/:sub_type', + keyBuilder.templateFileKeyMiddleware, + fileController.getFile + ) + app.post( + '/template/:template_id/v/:version/:format', + keyBuilder.templateFileKeyMiddleware, + fileController.insertFile + ) +} app.get( '/bucket/:bucket/key/*', diff --git a/services/filestore/buildscript.txt b/services/filestore/buildscript.txt index d40b7669c9..75a491c18a 100644 --- a/services/filestore/buildscript.txt +++ b/services/filestore/buildscript.txt @@ -7,6 +7,6 @@ filestore --esmock-loader=False --node-version=20.18.2 --public-repo=True ---script-version=4.5.0 +--script-version=4.7.0 --test-acceptance-shards=SHARD_01_,SHARD_02_,SHARD_03_ --use-large-ci-runner=True diff --git a/services/history-v1/api/controllers/projects.js b/services/history-v1/api/controllers/projects.js index 7dbeb53fae..d42e29ab81 100644 --- a/services/history-v1/api/controllers/projects.js +++ b/services/history-v1/api/controllers/projects.js @@ -138,6 +138,45 @@ async function getHistoryBefore(req, res, next) { } } +/** + * Get all changes since the beginning of history or since a given version + */ +async function getChanges(req, res, next) { + const projectId = req.swagger.params.project_id.value + const since = req.swagger.params.since.value ?? 0 + + if (since < 0) { + // Negative values would cause an infinite loop + return res.status(400).json({ + error: `Version out of bounds: ${since}`, + }) + } + + const changes = [] + let chunk = await chunkStore.loadLatest(projectId) + + if (since > chunk.getEndVersion()) { + return res.status(400).json({ + error: `Version out of bounds: ${since}`, + }) + } + + // Fetch all chunks that come after the chunk that contains the start version + while (chunk.getStartVersion() > since) { + const changesInChunk = chunk.getChanges() + changes.unshift(...changesInChunk) + chunk = await chunkStore.loadAtVersion(projectId, chunk.getStartVersion()) + } + + // Extract the relevant changes from the chunk that contains the start version + const changesInChunk = chunk + .getChanges() + .slice(since - chunk.getStartVersion()) + changes.unshift(...changesInChunk) + + res.json(changes.map(change => change.toRaw())) +} + async function getZip(req, res, next) { const projectId = req.swagger.params.project_id.value const version = req.swagger.params.version.value @@ -337,6 +376,7 @@ module.exports = { getLatestHistoryRaw: expressify(getLatestHistoryRaw), getHistory: expressify(getHistory), getHistoryBefore: expressify(getHistoryBefore), + getChanges: expressify(getChanges), getZip: expressify(getZip), createZip: expressify(createZip), deleteProject: expressify(deleteProject), diff --git a/services/history-v1/api/swagger/project_import.js b/services/history-v1/api/swagger/project_import.js index 60eb47fce4..a93f42d27e 100644 --- a/services/history-v1/api/swagger/project_import.js +++ b/services/history-v1/api/swagger/project_import.js @@ -100,9 +100,48 @@ const importChanges = { ], } +const getChanges = { + 'x-swagger-router-controller': 'projects', + operationId: 'getChanges', + tags: ['Project'], + description: 'Get changes applied to a project', + parameters: [ + { + name: 'project_id', + in: 'path', + description: 'project id', + required: true, + type: 'string', + }, + { + name: 'since', + in: 'query', + description: 'start version', + required: false, + type: 'number', + }, + ], + responses: { + 200: { + description: 'Success', + schema: { + type: 'array', + items: { + $ref: '#/definitions/Change', + }, + }, + }, + }, + security: [ + { + basic: [], + }, + ], +} + exports.paths = { '/projects/{project_id}/import': { post: importSnapshot }, '/projects/{project_id}/legacy_import': { post: importSnapshot }, - '/projects/{project_id}/changes': { post: importChanges }, + '/projects/{project_id}/changes': { get: getChanges, post: importChanges }, '/projects/{project_id}/legacy_changes': { post: importChanges }, } diff --git a/services/history-v1/backup-verifier-app.mjs b/services/history-v1/backup-verifier-app.mjs index 3949e6a62d..856a15dd53 100644 --- a/services/history-v1/backup-verifier-app.mjs +++ b/services/history-v1/backup-verifier-app.mjs @@ -90,15 +90,16 @@ process.on('SIGINT', () => { /** * @param {number} port + * @param {boolean} enableVerificationLoop * @return {Promise} */ -export async function startApp(port) { +export async function startApp(port, enableVerificationLoop = true) { await mongodb.client.connect() await loadGlobalBlobs() await healthCheck() const server = http.createServer(app) await promisify(server.listen.bind(server, port))() - loopRandomProjects(shutdownEmitter) + enableVerificationLoop && loopRandomProjects(shutdownEmitter) return server } diff --git a/services/history-v1/buildscript.txt b/services/history-v1/buildscript.txt index f8c895901b..f3e029ba33 100644 --- a/services/history-v1/buildscript.txt +++ b/services/history-v1/buildscript.txt @@ -6,5 +6,5 @@ history-v1 --esmock-loader=False --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 --tsconfig-extra-includes=backup-deletion-app.mjs,backup-verifier-app.mjs,backup-worker-app.mjs,api/**/*,migrations/**/*,storage/**/* diff --git a/services/history-v1/config/custom-environment-variables.json b/services/history-v1/config/custom-environment-variables.json index daf804251c..d07ae2925a 100644 --- a/services/history-v1/config/custom-environment-variables.json +++ b/services/history-v1/config/custom-environment-variables.json @@ -89,6 +89,16 @@ "host": "QUEUES_REDIS_HOST", "password": "QUEUES_REDIS_PASSWORD", "port": "QUEUES_REDIS_PORT" + }, + "history": { + "host": "HISTORY_REDIS_HOST", + "password": "HISTORY_REDIS_PASSWORD", + "port": "HISTORY_REDIS_PORT" + }, + "lock": { + "host": "REDIS_HOST", + "password": "REDIS_PASSWORD", + "port": "REDIS_PORT" } } } diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml index 7245ef14e2..06d5d55161 100644 --- a/services/history-v1/docker-compose.ci.yml +++ b/services/history-v1/docker-compose.ci.yml @@ -21,6 +21,7 @@ services: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis QUEUES_REDIS_HOST: redis + HISTORY_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo POSTGRES_HOST: postgres diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml index 608dd1d325..f4c885d467 100644 --- a/services/history-v1/docker-compose.yml +++ b/services/history-v1/docker-compose.yml @@ -37,6 +37,7 @@ services: environment: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis + HISTORY_REDIS_HOST: redis QUEUES_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo diff --git a/services/history-v1/migrations/20250415210802_add_chunks_closed.js b/services/history-v1/migrations/20250415210802_add_chunks_closed.js new file mode 100644 index 0000000000..b5c1d577f9 --- /dev/null +++ b/services/history-v1/migrations/20250415210802_add_chunks_closed.js @@ -0,0 +1,27 @@ +// @ts-check + +/** + * @import { Knex } from "knex" + */ + +/** + * @param { Knex } knex + * @returns { Promise } + */ +exports.up = async function (knex) { + await knex.raw(` + ALTER TABLE chunks + ADD COLUMN closed BOOLEAN NOT NULL DEFAULT FALSE + `) +} + +/** + * @param { Knex } knex + * @returns { Promise } + */ +exports.down = async function (knex) { + await knex.raw(` + ALTER TABLE chunks + DROP COLUMN closed + `) +} diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index 238cd12a38..5fe283a34c 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -1,10 +1,12 @@ exports.BatchBlobStore = require('./lib/batch_blob_store') exports.blobHash = require('./lib/blob_hash') exports.HashCheckBlobStore = require('./lib/hash_check_blob_store') +exports.chunkBuffer = require('./lib/chunk_buffer') exports.chunkStore = require('./lib/chunk_store') exports.historyStore = require('./lib/history_store').historyStore exports.knex = require('./lib/knex') exports.mongodb = require('./lib/mongodb') +exports.redis = require('./lib/redis') exports.persistChanges = require('./lib/persist_changes') exports.persistor = require('./lib/persistor') exports.ProjectArchive = require('./lib/project_archive') @@ -18,3 +20,6 @@ exports.loadGlobalBlobs = loadGlobalBlobs const { InvalidChangeError } = require('./lib/errors') exports.InvalidChangeError = InvalidChangeError + +const { ChunkVersionConflictError } = require('./lib/chunk_store/errors') +exports.ChunkVersionConflictError = ChunkVersionConflictError diff --git a/services/history-v1/storage/lib/assert.js b/services/history-v1/storage/lib/assert.js index d0ce318b4d..91f24da7e0 100644 --- a/services/history-v1/storage/lib/assert.js +++ b/services/history-v1/storage/lib/assert.js @@ -1,5 +1,7 @@ 'use strict' +const OError = require('@overleaf/o-error') + const check = require('check-types') const { Blob } = require('overleaf-editor-core') @@ -7,41 +9,58 @@ const assert = check.assert const MONGO_ID_REGEXP = /^[0-9a-f]{24}$/ const POSTGRES_ID_REGEXP = /^[1-9][0-9]{0,9}$/ -const PROJECT_ID_REGEXP = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/ +const MONGO_OR_POSTGRES_ID_REGEXP = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/ function transaction(transaction, message) { assert.function(transaction, message) } function blobHash(arg, message) { - assert.match(arg, Blob.HEX_HASH_RX, message) + try { + assert.match(arg, Blob.HEX_HASH_RX, message) + } catch (error) { + throw OError.tag(error, message, { arg }) + } +} + +/** + * A project id is a string that contains either an integer (for projects stored in Postgres) or 24 + * hex digits (for projects stored in Mongo) + */ +function projectId(arg, message) { + try { + assert.match(arg, MONGO_OR_POSTGRES_ID_REGEXP, message) + } catch (error) { + throw OError.tag(error, message, { arg }) + } } /** * A chunk id is a string that contains either an integer (for projects stored in Postgres) or 24 * hex digits (for projects stored in Mongo) */ -function projectId(arg, message) { - assert.match(arg, PROJECT_ID_REGEXP, message) -} - -/** - * A chunk id is either a number (for projects stored in Postgres) or a 24 - * character string (for projects stored in Mongo) - */ function chunkId(arg, message) { - const valid = check.integer(arg) || check.match(arg, MONGO_ID_REGEXP) - if (!valid) { - throw new TypeError(message) + try { + assert.match(arg, MONGO_OR_POSTGRES_ID_REGEXP, message) + } catch (error) { + throw OError.tag(error, message, { arg }) } } function mongoId(arg, message) { - assert.match(arg, MONGO_ID_REGEXP) + try { + assert.match(arg, MONGO_ID_REGEXP, message) + } catch (error) { + throw OError.tag(error, message, { arg }) + } } function postgresId(arg, message) { - assert.match(arg, POSTGRES_ID_REGEXP, message) + try { + assert.match(arg, POSTGRES_ID_REGEXP, message) + } catch (error) { + throw OError.tag(error, message, { arg }) + } } module.exports = { diff --git a/services/history-v1/storage/lib/backupGenerator.mjs b/services/history-v1/storage/lib/backupGenerator.mjs index f6b1b01a31..4c18929d54 100644 --- a/services/history-v1/storage/lib/backupGenerator.mjs +++ b/services/history-v1/storage/lib/backupGenerator.mjs @@ -2,11 +2,7 @@ * Provides a generator function to back up project chunks and blobs. */ -import { - getProjectChunksFromVersion, - loadAtVersion, - loadByChunkRecord, -} from './chunk_store/index.js' +import chunkStore from './chunk_store/index.js' import { GLOBAL_BLOBS, // NOTE: must call loadGlobalBlobs() before using this @@ -33,7 +29,10 @@ async function lookBehindForSeenBlobs( ) { // the snapshot in this chunk has not been backed up // so we find the set of backed up blobs from the previous chunk - const previousChunk = await loadAtVersion(projectId, lastBackedUpVersion) + const previousChunk = await chunkStore.loadAtVersion( + projectId, + lastBackedUpVersion + ) const previousChunkHistory = previousChunk.getHistory() previousChunkHistory.findBlobHashes(seenBlobs) } @@ -115,13 +114,13 @@ export async function* backupGenerator(projectId, lastBackedUpVersion) { lastBackedUpVersion >= 0 ? lastBackedUpVersion + 1 : 0 let isStartingChunk = true let currentBackedUpVersion = lastBackedUpVersion - const chunkRecordIterator = getProjectChunksFromVersion( + const chunkRecordIterator = chunkStore.getProjectChunksFromVersion( projectId, firstPendingVersion ) for await (const chunkRecord of chunkRecordIterator) { - const { chunk, chunkBuffer } = await loadByChunkRecord( + const { chunk, chunkBuffer } = await chunkStore.loadByChunkRecord( projectId, chunkRecord ) diff --git a/services/history-v1/storage/lib/blob_store/postgres.js b/services/history-v1/storage/lib/blob_store/postgres.js index 7f66d2d24d..1cedeec5d7 100644 --- a/services/history-v1/storage/lib/blob_store/postgres.js +++ b/services/history-v1/storage/lib/blob_store/postgres.js @@ -13,7 +13,7 @@ async function initialize(projectId) { * Return blob metadata for the given project and hash */ async function findBlob(projectId, hash) { - assert.postgresId(projectId, `bad projectId ${projectId}`) + assert.postgresId(projectId, 'bad projectId') projectId = parseInt(projectId, 10) assert.blobHash(hash, 'bad hash') @@ -35,7 +35,7 @@ async function findBlob(projectId, hash) { * @return {Promise.>} no guarantee on order */ async function findBlobs(projectId, hashes) { - assert.postgresId(projectId, `bad projectId ${projectId}`) + assert.postgresId(projectId, 'bad projectId') projectId = parseInt(projectId, 10) assert.array(hashes, 'bad hashes: not array') hashes.forEach(function (hash) { @@ -57,7 +57,7 @@ async function findBlobs(projectId, hashes) { * Return metadata for all blobs in the given project */ async function getProjectBlobs(projectId) { - assert.postgresId(projectId, `bad projectId ${projectId}`) + assert.postgresId(projectId, 'bad projectId') projectId = parseInt(projectId, 10) const records = await knex('project_blobs') @@ -103,7 +103,7 @@ async function getProjectBlobsBatch(projectIds) { * Add a blob's metadata to the blobs table after it has been uploaded. */ async function insertBlob(projectId, blob) { - assert.postgresId(projectId, `bad projectId ${projectId}`) + assert.postgresId(projectId, 'bad projectId') projectId = parseInt(projectId, 10) await knex('project_blobs') @@ -116,7 +116,7 @@ async function insertBlob(projectId, blob) { * Deletes all blobs for a given project */ async function deleteBlobs(projectId) { - assert.postgresId(projectId, `bad projectId ${projectId}`) + assert.postgresId(projectId, 'bad projectId') projectId = parseInt(projectId, 10) await knex('project_blobs').where('project_id', projectId).delete() diff --git a/services/history-v1/storage/lib/chunk_buffer/index.js b/services/history-v1/storage/lib/chunk_buffer/index.js new file mode 100644 index 0000000000..5ef533ddba --- /dev/null +++ b/services/history-v1/storage/lib/chunk_buffer/index.js @@ -0,0 +1,39 @@ +'use strict' + +/** + * @module storage/lib/chunk_buffer + */ + +const chunkStore = require('../chunk_store') +const redisBackend = require('../chunk_store/redis') +const metrics = require('@overleaf/metrics') +/** + * Load the latest Chunk stored for a project, including blob metadata. + * + * @param {string} projectId + * @return {Promise.} + */ +async function loadLatest(projectId) { + const chunkRecord = await chunkStore.loadLatestRaw(projectId) + const cachedChunk = await redisBackend.getCurrentChunkIfValid( + projectId, + chunkRecord + ) + if (cachedChunk) { + metrics.inc('chunk_buffer.loadLatest', 1, { + status: 'cache-hit', + }) + return cachedChunk + } else { + metrics.inc('chunk_buffer.loadLatest', 1, { + status: 'cache-miss', + }) + const chunk = await chunkStore.loadLatest(projectId) + await redisBackend.setCurrentChunk(projectId, chunk) + return chunk + } +} + +module.exports = { + loadLatest, +} diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index c1fbb9d607..f75c017552 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -1,3 +1,5 @@ +// @ts-check + 'use strict' /** @@ -156,7 +158,6 @@ async function loadAtTimestamp(projectId, timestamp) { * @param {string} projectId * @param {Chunk} chunk * @param {Date} [earliestChangeTimestamp] - * @return {Promise.} for the chunkId of the inserted chunk */ async function create(projectId, chunk, earliestChangeTimestamp) { assert.projectId(projectId, 'bad projectId') @@ -164,13 +165,18 @@ async function create(projectId, chunk, earliestChangeTimestamp) { assert.maybe.date(earliestChangeTimestamp, 'bad timestamp') const backend = getBackend(projectId) + const chunkStart = chunk.getStartVersion() const chunkId = await uploadChunk(projectId, chunk) - await backend.confirmCreate( - projectId, - chunk, - chunkId, - earliestChangeTimestamp - ) + + const opts = {} + if (chunkStart > 0) { + opts.oldChunkId = await getChunkIdForVersion(projectId, chunkStart - 1) + } + if (earliestChangeTimestamp != null) { + opts.earliestChangeTimestamp = earliestChangeTimestamp + } + + await backend.confirmCreate(projectId, chunk, chunkId, opts) } /** @@ -220,13 +226,12 @@ async function update( const oldChunkId = await getChunkIdForVersion(projectId, oldEndVersion) const newChunkId = await uploadChunk(projectId, newChunk) - await backend.confirmUpdate( - projectId, - oldChunkId, - newChunk, - newChunkId, - earliestChangeTimestamp - ) + const opts = {} + if (earliestChangeTimestamp != null) { + opts.earliestChangeTimestamp = earliestChangeTimestamp + } + + await backend.confirmUpdate(projectId, oldChunkId, newChunk, newChunkId, opts) } /** @@ -234,7 +239,7 @@ async function update( * * @param {string} projectId * @param {number} version - * @return {Promise.} + * @return {Promise.} */ async function getChunkIdForVersion(projectId, version) { const backend = getBackend(projectId) @@ -343,10 +348,14 @@ async function deleteProjectChunks(projectId) { * Delete a given number of old chunks from both the database * and from object storage. * - * @param {number} count - number of chunks to delete - * @param {number} minAgeSecs - how many seconds ago must chunks have been - * deleted - * @return {Promise} + * @param {object} options + * @param {number} [options.batchSize] - number of chunks to delete in each + * batch + * @param {number} [options.maxBatches] - maximum number of batches to process + * @param {number} [options.minAgeSecs] - minimum age of chunks to delete + * @param {number} [options.timeout] - maximum time to spend deleting chunks + * + * @return {Promise} number of chunks deleted */ async function deleteOldChunks(options = {}) { const batchSize = options.batchSize ?? DEFAULT_DELETE_BATCH_SIZE diff --git a/services/history-v1/storage/lib/chunk_store/mongo.js b/services/history-v1/storage/lib/chunk_store/mongo.js index bb93679fec..a34b7194af 100644 --- a/services/history-v1/storage/lib/chunk_store/mongo.js +++ b/services/history-v1/storage/lib/chunk_store/mongo.js @@ -1,4 +1,6 @@ -const { ObjectId, ReadPreference } = require('mongodb') +// @ts-check + +const { ObjectId, ReadPreference, MongoError } = require('mongodb') const { Chunk } = require('overleaf-editor-core') const OError = require('@overleaf/o-error') const assert = require('../assert') @@ -7,6 +9,10 @@ const { ChunkVersionConflictError } = require('./errors') const DUPLICATE_KEY_ERROR_CODE = 11000 +/** + * @import { ClientSession } from 'mongodb' + */ + /** * Get the latest chunk's metadata from the database * @param {string} projectId @@ -18,7 +24,10 @@ async function getLatestChunk(projectId, opts = {}) { const { readOnly = false } = opts const record = await mongodb.chunks.findOne( - { projectId: new ObjectId(projectId), state: 'active' }, + { + projectId: new ObjectId(projectId), + state: { $in: ['active', 'closed'] }, + }, { sort: { startVersion: -1 }, readPreference: readOnly @@ -42,7 +51,7 @@ async function getChunkForVersion(projectId, version) { const record = await mongodb.chunks.findOne( { projectId: new ObjectId(projectId), - state: 'active', + state: { $in: ['active', 'closed'] }, startVersion: { $lte: version }, endVersion: { $gte: version }, }, @@ -94,7 +103,7 @@ async function getChunkForTimestamp(projectId, timestamp) { const record = await mongodb.chunks.findOne( { projectId: new ObjectId(projectId), - state: 'active', + state: { $in: ['active', 'closed'] }, endTimestamp: { $gte: timestamp }, }, // We use the index on the startVersion for sorting records. This assumes @@ -126,7 +135,7 @@ async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) { const record = await mongodb.chunks.findOne( { projectId: new ObjectId(projectId), - state: 'active', + state: { $in: ['active', 'closed'] }, $or: [ { endTimestamp: { @@ -155,7 +164,10 @@ async function getProjectChunkIds(projectId) { assert.mongoId(projectId, 'bad projectId') const cursor = mongodb.chunks.find( - { projectId: new ObjectId(projectId), state: 'active' }, + { + projectId: new ObjectId(projectId), + state: { $in: ['active', 'closed'] }, + }, { projection: { _id: 1 } } ) return await cursor.map(record => record._id).toArray() @@ -169,7 +181,10 @@ async function getProjectChunks(projectId) { const cursor = mongodb.chunks .find( - { projectId: new ObjectId(projectId), state: 'active' }, + { + projectId: new ObjectId(projectId), + state: { $in: ['active', 'closed'] }, + }, { projection: { state: 0 } } ) .sort({ startVersion: 1 }) @@ -198,48 +213,35 @@ async function insertPendingChunk(projectId, chunk) { /** * Record that a new chunk was created. + * + * @param {string} projectId + * @param {Chunk} chunk + * @param {string} chunkId + * @param {object} opts + * @param {Date} [opts.earliestChangeTimestamp] + * @param {string} [opts.oldChunkId] */ -async function confirmCreate( - projectId, - chunk, - chunkId, - earliestChangeTimestamp, - mongoOpts = {} -) { +async function confirmCreate(projectId, chunk, chunkId, opts = {}) { assert.mongoId(projectId, 'bad projectId') - assert.instance(chunk, Chunk, 'bad chunk') - assert.mongoId(chunkId, 'bad chunkId') + assert.instance(chunk, Chunk, 'bad newChunk') + assert.mongoId(chunkId, 'bad newChunkId') - let result - try { - result = await mongodb.chunks.updateOne( - { - _id: new ObjectId(chunkId), - projectId: new ObjectId(projectId), - state: 'pending', - }, - { $set: { state: 'active', updatedAt: new Date() } }, - mongoOpts - ) - } catch (err) { - if (err.code === DUPLICATE_KEY_ERROR_CODE) { - throw new ChunkVersionConflictError('chunk start version is not unique', { + await mongodb.client.withSession(async session => { + await session.withTransaction(async () => { + if (opts.oldChunkId != null) { + await closeChunk(projectId, opts.oldChunkId, { session }) + } + + await activateChunk(projectId, chunkId, { session }) + + await updateProjectRecord( projectId, - chunkId, - }) - } else { - throw err - } - } - if (result.matchedCount === 0) { - throw new OError('pending chunk not found', { projectId, chunkId }) - } - await updateProjectRecord( - projectId, - chunk, - earliestChangeTimestamp, - mongoOpts - ) + chunk, + opts.earliestChangeTimestamp, + { session } + ) + }) + }) } /** @@ -276,41 +278,145 @@ async function updateProjectRecord( /** * Record that a chunk was replaced by a new one. + * + * @param {string} projectId + * @param {string} oldChunkId + * @param {Chunk} newChunk + * @param {string} newChunkId + * @param {object} [opts] + * @param {Date} [opts.earliestChangeTimestamp] */ async function confirmUpdate( projectId, oldChunkId, newChunk, newChunkId, - earliestChangeTimestamp + opts = {} ) { assert.mongoId(projectId, 'bad projectId') assert.mongoId(oldChunkId, 'bad oldChunkId') assert.instance(newChunk, Chunk, 'bad newChunk') assert.mongoId(newChunkId, 'bad newChunkId') - const session = mongodb.client.startSession() - try { + await mongodb.client.withSession(async session => { await session.withTransaction(async () => { - await deleteChunk(projectId, oldChunkId, { session }) - await confirmCreate( + await deleteActiveChunk(projectId, oldChunkId, { session }) + + await activateChunk(projectId, newChunkId, { session }) + + await updateProjectRecord( projectId, newChunk, - newChunkId, - earliestChangeTimestamp, + opts.earliestChangeTimestamp, { session } ) }) - } finally { - await session.endSession() + }) +} + +/** + * Activate a pending chunk + * + * @param {string} projectId + * @param {string} chunkId + * @param {object} [opts] + * @param {ClientSession} [opts.session] + */ +async function activateChunk(projectId, chunkId, opts = {}) { + assert.mongoId(projectId, 'bad projectId') + assert.mongoId(chunkId, 'bad chunkId') + + let result + try { + result = await mongodb.chunks.updateOne( + { + _id: new ObjectId(chunkId), + projectId: new ObjectId(projectId), + state: 'pending', + }, + { $set: { state: 'active', updatedAt: new Date() } }, + opts + ) + } catch (err) { + if (err instanceof MongoError && err.code === DUPLICATE_KEY_ERROR_CODE) { + throw new ChunkVersionConflictError('chunk start version is not unique', { + projectId, + chunkId, + }) + } else { + throw err + } + } + if (result.matchedCount === 0) { + throw new OError('pending chunk not found', { projectId, chunkId }) + } +} + +/** + * Close a chunk + * + * A closed chunk is one that can't be extended anymore. + * + * @param {string} projectId + * @param {string} chunkId + * @param {object} [opts] + * @param {ClientSession} [opts.session] + */ +async function closeChunk(projectId, chunkId, opts = {}) { + const result = await mongodb.chunks.updateOne( + { + _id: new ObjectId(chunkId), + projectId: new ObjectId(projectId), + state: 'active', + }, + { $set: { state: 'closed' } }, + opts + ) + + if (result.matchedCount === 0) { + throw new ChunkVersionConflictError('unable to close chunk', { + projectId, + chunkId, + }) + } +} + +/** + * Delete an active chunk + * + * This is used to delete chunks that are in the process of being extended. It + * will refuse to delete chunks that are already closed and can therefore not be + * extended. + * + * @param {string} projectId + * @param {string} chunkId + * @param {object} [opts] + * @param {ClientSession} [opts.session] + */ +async function deleteActiveChunk(projectId, chunkId, opts = {}) { + const updateResult = await mongodb.chunks.updateOne( + { + _id: new ObjectId(chunkId), + projectId: new ObjectId(projectId), + state: 'active', + }, + { $set: { state: 'deleted', updatedAt: new Date() } }, + opts + ) + + if (updateResult.matchedCount === 0) { + throw new ChunkVersionConflictError('unable to delete active chunk', { + projectId, + chunkId, + }) } } /** * Delete a chunk. * - * @param {number} projectId - * @param {number} chunkId + * @param {string} projectId + * @param {string} chunkId * @return {Promise} */ async function deleteChunk(projectId, chunkId, mongoOpts = {}) { @@ -331,7 +437,10 @@ async function deleteProjectChunks(projectId) { assert.mongoId(projectId, 'bad projectId') await mongodb.chunks.updateMany( - { projectId: new ObjectId(projectId), state: 'active' }, + { + projectId: new ObjectId(projectId), + state: { $in: ['active', 'closed'] }, + }, { $set: { state: 'deleted', updatedAt: new Date() } } ) } diff --git a/services/history-v1/storage/lib/chunk_store/postgres.js b/services/history-v1/storage/lib/chunk_store/postgres.js index 0964b0ecca..0c33c0fd82 100644 --- a/services/history-v1/storage/lib/chunk_store/postgres.js +++ b/services/history-v1/storage/lib/chunk_store/postgres.js @@ -1,3 +1,5 @@ +// @ts-check + const { Chunk } = require('overleaf-editor-core') const assert = require('../assert') const knex = require('../knex') @@ -7,6 +9,10 @@ const { updateProjectRecord } = require('./mongo') const DUPLICATE_KEY_ERROR_CODE = '23505' +/** + * @import { Knex } from 'knex' + */ + /** * Get the latest chunk's metadata from the database * @param {string} projectId @@ -14,12 +20,11 @@ const DUPLICATE_KEY_ERROR_CODE = '23505' * @param {boolean} [opts.readOnly] */ async function getLatestChunk(projectId, opts = {}) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') const { readOnly = false } = opts const record = await (readOnly ? knexReadOnly : knex)('chunks') - .where('doc_id', projectId) + .where('doc_id', parseInt(projectId, 10)) .orderBy('end_version', 'desc') .first() if (record == null) { @@ -30,13 +35,15 @@ async function getLatestChunk(projectId, opts = {}) { /** * Get the metadata for the chunk that contains the given version. + * + * @param {string} projectId + * @param {number} version */ async function getChunkForVersion(projectId, version) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') const record = await knex('chunks') - .where('doc_id', projectId) + .where('doc_id', parseInt(projectId, 10)) .where('end_version', '>=', version) .orderBy('end_version') .first() @@ -48,20 +55,23 @@ async function getChunkForVersion(projectId, version) { /** * Get the metadata for the chunk that contains the given version. + * + * @param {string} projectId + * @param {Date} timestamp */ async function getFirstChunkBeforeTimestamp(projectId, timestamp) { assert.date(timestamp, 'bad timestamp') const recordActive = await getChunkForVersion(projectId, 0) + // projectId must be valid if getChunkForVersion did not throw - projectId = parseInt(projectId, 10) if (recordActive && recordActive.endTimestamp <= timestamp) { return recordActive } // fallback to deleted chunk const recordDeleted = await knex('old_chunks') - .where('doc_id', projectId) + .where('doc_id', parseInt(projectId, 10)) .where('start_version', '=', 0) .where('end_timestamp', '<=', timestamp) .orderBy('end_version', 'desc') @@ -75,14 +85,16 @@ async function getFirstChunkBeforeTimestamp(projectId, timestamp) { /** * Get the metadata for the chunk that contains the version that was current at * the given timestamp. + * + * @param {string} projectId + * @param {Date} timestamp */ async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) { assert.date(timestamp, 'bad timestamp') assert.postgresId(projectId, 'bad projectId') - projectId = parseInt(projectId, 10) const query = knex('chunks') - .where('doc_id', projectId) + .where('doc_id', parseInt(projectId, 10)) .where(function () { this.where('end_timestamp', '<=', timestamp).orWhere( 'end_timestamp', @@ -102,10 +114,12 @@ async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) { /** * Get the metadata for the chunk that contains the version that was current at * the given timestamp. + * + * @param {string} projectId + * @param {Date} timestamp */ async function getChunkForTimestamp(projectId, timestamp) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') // This query will find the latest chunk after the timestamp (query orders // in reverse chronological order), OR the latest chunk @@ -118,11 +132,11 @@ async function getChunkForTimestamp(projectId, timestamp) { 'WHERE doc_id = ? ' + 'ORDER BY end_version desc LIMIT 1' + ')', - [timestamp, projectId] + [timestamp, parseInt(projectId, 10)] ) const record = await knex('chunks') - .where('doc_id', projectId) + .where('doc_id', parseInt(projectId, 10)) .where(whereAfterEndTimestampOrLatestChunk) .orderBy('end_version') .first() @@ -137,7 +151,7 @@ async function getChunkForTimestamp(projectId, timestamp) { */ function chunkFromRecord(record) { return { - id: record.id, + id: record.id.toString(), startVersion: record.start_version, endVersion: record.end_version, endTimestamp: record.end_timestamp, @@ -146,35 +160,41 @@ function chunkFromRecord(record) { /** * Get all of a project's chunk ids + * + * @param {string} projectId */ async function getProjectChunkIds(projectId) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') - const records = await knex('chunks').select('id').where('doc_id', projectId) + const records = await knex('chunks') + .select('id') + .where('doc_id', parseInt(projectId, 10)) return records.map(record => record.id) } /** * Get all of a projects chunks directly + * + * @param {string} projectId */ async function getProjectChunks(projectId) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') const records = await knex('chunks') .select() - .where('doc_id', projectId) + .where('doc_id', parseInt(projectId, 10)) .orderBy('end_version') return records.map(chunkFromRecord) } /** * Insert a pending chunk before sending it to object storage. + * + * @param {string} projectId + * @param {Chunk} chunk */ async function insertPendingChunk(projectId, chunk) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') const result = await knex.first( knex.raw("nextval('chunks_id_seq'::regclass)::integer as chunkid") @@ -182,80 +202,119 @@ async function insertPendingChunk(projectId, chunk) { const chunkId = result.chunkid await knex('pending_chunks').insert({ id: chunkId, - doc_id: projectId, + doc_id: parseInt(projectId, 10), end_version: chunk.getEndVersion(), start_version: chunk.getStartVersion(), end_timestamp: chunk.getEndTimestamp(), }) - return chunkId + return chunkId.toString() } /** * Record that a new chunk was created. + * + * @param {string} projectId + * @param {Chunk} chunk + * @param {string} chunkId + * @param {object} opts + * @param {Date} [opts.earliestChangeTimestamp] + * @param {string} [opts.oldChunkId] */ -async function confirmCreate( - projectId, - chunk, - chunkId, - earliestChangeTimestamp -) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) +async function confirmCreate(projectId, chunk, chunkId, opts = {}) { + assert.postgresId(projectId, 'bad projectId') await knex.transaction(async tx => { + if (opts.oldChunkId != null) { + await _assertChunkIsNotClosed(tx, projectId, opts.oldChunkId) + await _closeChunk(tx, projectId, opts.oldChunkId) + } await Promise.all([ _deletePendingChunk(tx, projectId, chunkId), _insertChunk(tx, projectId, chunk, chunkId), ]) - await updateProjectRecord(projectId, chunk, earliestChangeTimestamp) + await updateProjectRecord( + // The history id in Mongo is an integer for Postgres projects + parseInt(projectId, 10), + chunk, + opts.earliestChangeTimestamp + ) }) } /** * Record that a chunk was replaced by a new one. + * + * @param {string} projectId + * @param {string} oldChunkId + * @param {Chunk} newChunk + * @param {string} newChunkId */ async function confirmUpdate( projectId, oldChunkId, newChunk, newChunkId, - earliestChangeTimestamp + opts = {} ) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') await knex.transaction(async tx => { + await _assertChunkIsNotClosed(tx, projectId, oldChunkId) await _deleteChunks(tx, { doc_id: projectId, id: oldChunkId }) await Promise.all([ _deletePendingChunk(tx, projectId, newChunkId), _insertChunk(tx, projectId, newChunk, newChunkId), ]) - await updateProjectRecord(projectId, newChunk, earliestChangeTimestamp) + await updateProjectRecord( + // The history id in Mongo is an integer for Postgres projects + parseInt(projectId, 10), + newChunk, + opts.earliestChangeTimestamp + ) }) } +/** + * Delete a pending chunk + * + * @param {Knex} tx + * @param {string} projectId + * @param {string} chunkId + */ async function _deletePendingChunk(tx, projectId, chunkId) { await tx('pending_chunks') .where({ - doc_id: projectId, - id: chunkId, + doc_id: parseInt(projectId, 10), + id: parseInt(chunkId, 10), }) .del() } +/** + * Adds an active chunk + * + * @param {Knex} tx + * @param {string} projectId + * @param {Chunk} chunk + * @param {string} chunkId + */ async function _insertChunk(tx, projectId, chunk, chunkId) { const startVersion = chunk.getStartVersion() const endVersion = chunk.getEndVersion() try { await tx('chunks').insert({ - id: chunkId, - doc_id: projectId, + id: parseInt(chunkId, 10), + doc_id: parseInt(projectId, 10), start_version: startVersion, end_version: endVersion, end_timestamp: chunk.getEndTimestamp(), }) } catch (err) { - if (err.code === DUPLICATE_KEY_ERROR_CODE) { + if ( + err instanceof Error && + 'code' in err && + err.code === DUPLICATE_KEY_ERROR_CODE + ) { throw new ChunkVersionConflictError( 'chunk start or end version is not unique', { projectId, chunkId, startVersion, endVersion } @@ -265,35 +324,92 @@ async function _insertChunk(tx, projectId, chunk, chunkId) { } } +/** + * Check that a chunk is not closed + * + * This is used to synchronize chunk creations and extensions. + * + * @param {Knex} tx + * @param {string} projectId + * @param {string} chunkId + */ +async function _assertChunkIsNotClosed(tx, projectId, chunkId) { + const record = await tx('chunks') + .forUpdate() + .select('closed') + .where('doc_id', parseInt(projectId, 10)) + .where('id', parseInt(chunkId, 10)) + .first() + if (!record) { + throw new ChunkVersionConflictError('unable to close chunk: not found', { + projectId, + chunkId, + }) + } + if (record.closed) { + throw new ChunkVersionConflictError( + 'unable to close chunk: already closed', + { + projectId, + chunkId, + } + ) + } +} + +/** + * Close a chunk + * + * A closed chunk can no longer be extended. + * + * @param {Knex} tx + * @param {string} projectId + * @param {string} chunkId + */ +async function _closeChunk(tx, projectId, chunkId) { + await tx('chunks') + .update({ closed: true }) + .where('doc_id', parseInt(projectId, 10)) + .where('id', parseInt(chunkId, 10)) +} + /** * Delete a chunk. * - * @param {number} projectId - * @param {number} chunkId - * @return {Promise} + * @param {string} projectId + * @param {string} chunkId */ async function deleteChunk(projectId, chunkId) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') assert.integer(chunkId, 'bad chunkId') - await _deleteChunks(knex, { doc_id: projectId, id: chunkId }) + await _deleteChunks(knex, { + doc_id: parseInt(projectId, 10), + id: parseInt(chunkId, 10), + }) } /** * Delete all of a project's chunks + * + * @param {string} projectId */ async function deleteProjectChunks(projectId) { - assert.postgresId(projectId, `bad projectId ${projectId}`) - projectId = parseInt(projectId, 10) + assert.postgresId(projectId, 'bad projectId') await knex.transaction(async tx => { - await _deleteChunks(knex, { doc_id: projectId }) + await _deleteChunks(knex, { doc_id: parseInt(projectId, 10) }) }) } +/** + * Delete many chunks + * + * @param {Knex} tx + * @param {any} whereClause + */ async function _deleteChunks(tx, whereClause) { - const rows = await tx('chunks').returning('*').where(whereClause).del() + const rows = await tx('chunks').where(whereClause).del().returning('*') if (rows.length === 0) { return } @@ -311,6 +427,9 @@ async function _deleteChunks(tx, whereClause) { /** * Get a batch of old chunks for deletion + * + * @param {number} count + * @param {number} minAgeSecs */ async function getOldChunksBatch(count, minAgeSecs) { const maxDeletedAt = new Date(Date.now() - minAgeSecs * 1000) @@ -321,15 +440,22 @@ async function getOldChunksBatch(count, minAgeSecs) { .limit(count) return records.map(oldChunk => ({ projectId: oldChunk.doc_id.toString(), - chunkId: oldChunk.chunk_id, + chunkId: oldChunk.chunk_id.toString(), })) } /** * Delete a batch of old chunks from the database + * + * @param {string[]} chunkIds */ async function deleteOldChunks(chunkIds) { - await knex('old_chunks').whereIn('chunk_id', chunkIds).del() + await knex('old_chunks') + .whereIn( + 'chunk_id', + chunkIds.map(id => parseInt(id, 10)) + ) + .del() } /** diff --git a/services/history-v1/storage/lib/chunk_store/redis.js b/services/history-v1/storage/lib/chunk_store/redis.js new file mode 100644 index 0000000000..d9c423861d --- /dev/null +++ b/services/history-v1/storage/lib/chunk_store/redis.js @@ -0,0 +1,478 @@ +const metrics = require('@overleaf/metrics') +const logger = require('@overleaf/logger') +const redis = require('../redis') +const rclient = redis.rclientHistory // +const { Snapshot, Change, History, Chunk } = require('overleaf-editor-core') + +const TEMPORARY_CACHE_LIFETIME = 300 // 5 minutes + +const keySchema = { + snapshot({ projectId }) { + return `snapshot:{${projectId}}` + }, + startVersion({ projectId }) { + return `snapshot-version:{${projectId}}` + }, + changes({ projectId }) { + return `changes:{${projectId}}` + }, + expireTime({ projectId }) { + return `expire-time:{${projectId}}` + }, + persistTime({ projectId }) { + return `persist-time:{${projectId}}` + }, +} + +rclient.defineCommand('get_current_chunk', { + numberOfKeys: 3, + lua: ` + local startVersionValue = redis.call('GET', KEYS[2]) + if not startVersionValue then + return nil -- this is a cache-miss + end + local snapshotValue = redis.call('GET', KEYS[1]) + local changesValues = redis.call('LRANGE', KEYS[3], 0, -1) + return {snapshotValue, startVersionValue, changesValues} + `, +}) + +/** + * Retrieves the current chunk of project history from Redis storage + * @param {string} projectId - The unique identifier of the project + * @returns {Promise} A Promise that resolves to a Chunk object containing project history, + * or null if retrieval fails + * @throws {Error} If Redis operations fail + */ +async function getCurrentChunk(projectId) { + try { + const result = await rclient.get_current_chunk( + keySchema.snapshot({ projectId }), + keySchema.startVersion({ projectId }), + keySchema.changes({ projectId }) + ) + if (!result) { + return null // cache-miss + } + const snapshot = Snapshot.fromRaw(JSON.parse(result[0])) + const startVersion = JSON.parse(result[1]) + const changes = result[2].map(c => Change.fromRaw(JSON.parse(c))) + const history = new History(snapshot, changes) + const chunk = new Chunk(history, startVersion) + metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'success' }) + return chunk + } catch (err) { + logger.error({ err, projectId }, 'error getting current chunk from redis') + metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'error' }) + return null + } +} + +rclient.defineCommand('get_current_chunk_if_valid', { + numberOfKeys: 3, + lua: ` + local expectedStartVersion = ARGV[1] + local expectedChangesCount = tonumber(ARGV[2]) + local startVersionValue = redis.call('GET', KEYS[2]) + if not startVersionValue then + return nil -- this is a cache-miss + end + if startVersionValue ~= expectedStartVersion then + return nil -- this is a cache-miss + end + local changesCount = redis.call('LLEN', KEYS[3]) + if changesCount ~= expectedChangesCount then + return nil -- this is a cache-miss + end + local snapshotValue = redis.call('GET', KEYS[1]) + local changesValues = redis.call('LRANGE', KEYS[3], 0, -1) + return {snapshotValue, startVersionValue, changesValues} + `, +}) + +async function getCurrentChunkIfValid(projectId, chunkRecord) { + try { + const changesCount = chunkRecord.endVersion - chunkRecord.startVersion + const result = await rclient.get_current_chunk_if_valid( + keySchema.snapshot({ projectId }), + keySchema.startVersion({ projectId }), + keySchema.changes({ projectId }), + chunkRecord.startVersion, + changesCount + ) + if (!result) { + return null // cache-miss + } + const snapshot = Snapshot.fromRaw(JSON.parse(result[0])) + const startVersion = parseInt(result[1], 10) + const changes = result[2].map(c => Change.fromRaw(JSON.parse(c))) + const history = new History(snapshot, changes) + const chunk = new Chunk(history, startVersion) + metrics.inc('chunk_store.redis.get_current_chunk_if_valid', 1, { + status: 'success', + }) + return chunk + } catch (err) { + logger.error( + { err, projectId, chunkRecord }, + 'error getting current chunk from redis' + ) + metrics.inc('chunk_store.redis.get_current_chunk_if_valid', 1, { + status: 'error', + }) + return null + } +} + +rclient.defineCommand('get_current_chunk_metadata', { + numberOfKeys: 2, + lua: ` + local startVersionValue = redis.call('GET', KEYS[1]) + if not startVersionValue then + return nil -- this is a cache-miss + end + local changesCount = redis.call('LLEN', KEYS[2]) + return {startVersionValue, changesCount} + `, +}) + +/** + * Retrieves the current chunk metadata for a given project from Redis + * @param {string} projectId - The ID of the project to get metadata for + * @returns {Promise} Object containing startVersion and changesCount if found, null on error or cache miss + * @property {number} startVersion - The starting version information + * @property {number} changesCount - The number of changes in the chunk + */ +async function getCurrentChunkMetadata(projectId) { + try { + const result = await rclient.get_current_chunk_metadata( + keySchema.startVersion({ projectId }), + keySchema.changes({ projectId }) + ) + if (!result) { + return null // cache-miss + } + const startVersion = JSON.parse(result[0]) + const changesCount = parseInt(result[1], 10) + return { startVersion, changesCount } + } catch (err) { + return null + } +} + +rclient.defineCommand('set_current_chunk', { + numberOfKeys: 4, + lua: ` + local snapshotValue = ARGV[1] + local startVersionValue = ARGV[2] + local expireTime = ARGV[3] + redis.call('SET', KEYS[1], snapshotValue) + redis.call('SET', KEYS[2], startVersionValue) + redis.call('SET', KEYS[3], expireTime) + redis.call('DEL', KEYS[4]) -- clear the old changes list + if #ARGV >= 4 then + redis.call('RPUSH', KEYS[4], unpack(ARGV, 4)) + end + + `, +}) + +/** + * Stores the current chunk of project history in Redis + * @param {string} projectId - The ID of the project + * @param {Chunk} chunk - The chunk object containing history data + * @returns {Promise<*>} Returns the result of the Redis operation, or null if an error occurs + * @throws {Error} May throw Redis-related errors which are caught internally + */ +async function setCurrentChunk(projectId, chunk) { + try { + const snapshotKey = keySchema.snapshot({ projectId }) + const startVersionKey = keySchema.startVersion({ projectId }) + const changesKey = keySchema.changes({ projectId }) + const expireTimeKey = keySchema.expireTime({ projectId }) + + const snapshot = chunk.history.snapshot + const startVersion = chunk.startVersion + const changes = chunk.history.changes + const expireTime = Date.now() + TEMPORARY_CACHE_LIFETIME * 1000 + + await rclient.set_current_chunk( + snapshotKey, // KEYS[1] + startVersionKey, // KEYS[2] + expireTimeKey, // KEYS[3] + changesKey, // KEYS[4] + JSON.stringify(snapshot.toRaw()), // ARGV[1] + startVersion, // ARGV[2] + expireTime, // ARGV[3] + ...changes.map(c => JSON.stringify(c.toRaw())) // ARGV[4..] + ) + metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'success' }) + } catch (err) { + logger.error( + { err, projectId, chunk }, + 'error setting current chunk in redis' + ) + metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'error' }) + return null // while testing we will suppress any errors + } +} + +/** + * Checks whether a cached chunk's version metadata matches the current chunk's metadata + * @param {Chunk} cachedChunk - The chunk retrieved from cache + * @param {Chunk} currentChunk - The current chunk to compare against + * @returns {boolean} - Returns true if the chunks have matching start and end versions, false otherwise + */ +function checkCacheValidity(cachedChunk, currentChunk) { + return Boolean( + cachedChunk && + cachedChunk.getStartVersion() === currentChunk.getStartVersion() && + cachedChunk.getEndVersion() === currentChunk.getEndVersion() + ) +} + +/** + * Validates if a cached chunk matches the current chunk metadata by comparing versions + * @param {Object} cachedChunk - The cached chunk object to validate + * @param {Object} currentChunkMetadata - The current chunk metadata to compare against + * @param {number} currentChunkMetadata.startVersion - The starting version number + * @param {number} currentChunkMetadata.endVersion - The ending version number + * @returns {boolean} - True if the cached chunk is valid, false otherwise + */ +function checkCacheValidityWithMetadata(cachedChunk, currentChunkMetadata) { + return Boolean( + cachedChunk && + cachedChunk.getStartVersion() === currentChunkMetadata.startVersion && + cachedChunk.getEndVersion() === currentChunkMetadata.endVersion + ) +} + +/** + * Compares two chunks for equality using stringified JSON comparison + * @param {string} projectId - The ID of the project + * @param {Chunk} cachedChunk - The cached chunk to compare + * @param {Chunk} currentChunk - The current chunk to compare against + * @returns {boolean} - Returns false if either chunk is null/undefined, otherwise returns the comparison result + */ +function compareChunks(projectId, cachedChunk, currentChunk) { + if (!cachedChunk || !currentChunk) { + return false + } + const identical = JSON.stringify(cachedChunk) === JSON.stringify(currentChunk) + if (!identical) { + try { + logger.error( + { + projectId, + cachedChunkStartVersion: cachedChunk.getStartVersion(), + cachedChunkEndVersion: cachedChunk.getEndVersion(), + currentChunkStartVersion: currentChunk.getStartVersion(), + currentChunkEndVersion: currentChunk.getEndVersion(), + }, + 'chunk cache mismatch' + ) + } catch (err) { + // ignore errors while logging + } + } + metrics.inc('chunk_store.redis.compare_chunks', 1, { + status: identical ? 'success' : 'fail', + }) + return identical +} + +// Define Lua script for atomic cache clearing +rclient.defineCommand('expire_chunk_cache', { + numberOfKeys: 5, + lua: ` + local persistTimeExists = redis.call('EXISTS', KEYS[5]) + if persistTimeExists == 1 then + return nil -- chunk has changes pending, do not expire + end + local currentTime = tonumber(ARGV[1]) + local expireTimeValue = redis.call('GET', KEYS[4]) + if not expireTimeValue then + return nil -- this is a cache-miss + end + local expireTime = tonumber(expireTimeValue) + if currentTime < expireTime then + return nil -- cache is still valid + end + -- Cache is expired and all changes are persisted, proceed to delete the keys atomically + redis.call('DEL', KEYS[1]) -- snapshot key + redis.call('DEL', KEYS[2]) -- startVersion key + redis.call('DEL', KEYS[3]) -- changes key + redis.call('DEL', KEYS[4]) -- expireTime key + return 1 + `, +}) + +/** + * Expire cache entries for a project's chunk data if needed + * @param {string} projectId - The ID of the project whose cache should be cleared + * @returns {Promise} A promise that resolves to true if successful, false on error + */ +async function expireCurrentChunk(projectId, currentTime) { + try { + const snapshotKey = keySchema.snapshot({ projectId }) + const startVersionKey = keySchema.startVersion({ projectId }) + const changesKey = keySchema.changes({ projectId }) + const expireTimeKey = keySchema.expireTime({ projectId }) + const persistTimeKey = keySchema.persistTime({ projectId }) + const result = await rclient.expire_chunk_cache( + snapshotKey, + startVersionKey, + changesKey, + expireTimeKey, + persistTimeKey, + currentTime || Date.now() + ) + if (!result) { + logger.debug( + { projectId }, + 'chunk cache not expired due to pending changes' + ) + metrics.inc('chunk_store.redis.expire_cache', 1, { + status: 'skip-due-to-pending-changes', + }) + return false // not expired + } + metrics.inc('chunk_store.redis.expire_cache', 1, { status: 'success' }) + return true + } catch (err) { + logger.error({ err, projectId }, 'error clearing chunk cache from redis') + metrics.inc('chunk_store.redis.expire_cache', 1, { status: 'error' }) + return false + } +} + +// Define Lua script for atomic cache clearing +rclient.defineCommand('clear_chunk_cache', { + numberOfKeys: 5, + lua: ` + local persistTimeExists = redis.call('EXISTS', KEYS[5]) + if persistTimeExists == 1 then + return nil -- chunk has changes pending, do not clear + end + -- Delete all keys related to a project's chunk cache atomically + redis.call('DEL', KEYS[1]) -- snapshot key + redis.call('DEL', KEYS[2]) -- startVersion key + redis.call('DEL', KEYS[3]) -- changes key + redis.call('DEL', KEYS[4]) -- expireTime key + return 1 + `, +}) + +/** + * Clears all cache entries for a project's chunk data + * @param {string} projectId - The ID of the project whose cache should be cleared + * @returns {Promise} A promise that resolves to true if successful, false on error + */ +async function clearCache(projectId) { + try { + const snapshotKey = keySchema.snapshot({ projectId }) + const startVersionKey = keySchema.startVersion({ projectId }) + const changesKey = keySchema.changes({ projectId }) + const expireTimeKey = keySchema.expireTime({ projectId }) + const persistTimeKey = keySchema.persistTime({ projectId }) // Add persistTimeKey + + const result = await rclient.clear_chunk_cache( + snapshotKey, + startVersionKey, + changesKey, + expireTimeKey, + persistTimeKey + ) + if (result === null) { + logger.debug( + { projectId }, + 'chunk cache not cleared due to pending changes' + ) + metrics.inc('chunk_store.redis.clear_cache', 1, { + status: 'skip-due-to-pending-changes', + }) + return false + } + metrics.inc('chunk_store.redis.clear_cache', 1, { status: 'success' }) + return true + } catch (err) { + logger.error({ err, projectId }, 'error clearing chunk cache from redis') + metrics.inc('chunk_store.redis.clear_cache', 1, { status: 'error' }) + return false + } +} + +// Define Lua script for getting chunk status +rclient.defineCommand('get_chunk_status', { + numberOfKeys: 2, // expireTimeKey, persistTimeKey + lua: ` + local expireTimeValue = redis.call('GET', KEYS[1]) + local persistTimeValue = redis.call('GET', KEYS[2]) + return {expireTimeValue, persistTimeValue} + `, +}) + +/** + * Retrieves the current chunk status for a given project from Redis + * @param {string} projectId - The ID of the project to get status for + * @returns {Promise} Object containing expireTime and persistTime, or nulls on error + * @property {number|null} expireTime - The expiration time of the chunk + * @property {number|null} persistTime - The persistence time of the chunk + */ +async function getCurrentChunkStatus(projectId) { + try { + const expireTimeKey = keySchema.expireTime({ projectId }) + const persistTimeKey = keySchema.persistTime({ projectId }) + + const result = await rclient.get_chunk_status(expireTimeKey, persistTimeKey) + + // Lua script returns an array [expireTimeValue, persistTimeValue] + // Redis nil replies are converted to null by ioredis + const [expireTime, persistTime] = result + + return { + expireTime: expireTime ? parseInt(expireTime, 10) : null, // Parse to number or null + persistTime: persistTime ? parseInt(persistTime, 10) : null, // Parse to number or null + } + } catch (err) { + logger.warn({ err, projectId }, 'error getting chunk status from redis') + return { expireTime: null, persistTime: null } // Return nulls on error + } +} + +/** + * Sets the persist time for a project's chunk cache. + * This is primarily intended for testing purposes. + * @param {string} projectId - The ID of the project. + * @param {number} timestamp - The timestamp to set as the persist time. + * @returns {Promise} + */ +async function setPersistTime(projectId, timestamp) { + try { + const persistTimeKey = keySchema.persistTime({ projectId }) + await rclient.set(persistTimeKey, timestamp) + metrics.inc('chunk_store.redis.set_persist_time', 1, { status: 'success' }) + } catch (err) { + logger.error( + { err, projectId, timestamp }, + 'error setting persist time in redis' + ) + metrics.inc('chunk_store.redis.set_persist_time', 1, { status: 'error' }) + // Re-throw the error so the test fails if setting fails + throw err + } +} + +module.exports = { + getCurrentChunk, + getCurrentChunkIfValid, + setCurrentChunk, + getCurrentChunkMetadata, + checkCacheValidity, + checkCacheValidityWithMetadata, + compareChunks, + expireCurrentChunk, + clearCache, + getCurrentChunkStatus, + setPersistTime, // Export the new function +} diff --git a/services/history-v1/storage/lib/history_store.js b/services/history-v1/storage/lib/history_store.js index d16820d74c..e51bdc25c5 100644 --- a/services/history-v1/storage/lib/history_store.js +++ b/services/history-v1/storage/lib/history_store.js @@ -25,8 +25,8 @@ const gunzip = promisify(zlib.gunzip) class LoadError extends OError { /** - * @param {number|string} projectId - * @param {number|string} chunkId + * @param {string} projectId + * @param {string} chunkId * @param {any} cause */ constructor(projectId, chunkId, cause) { @@ -42,8 +42,8 @@ class LoadError extends OError { class StoreError extends OError { /** - * @param {number|string} projectId - * @param {number|string} chunkId + * @param {string} projectId + * @param {string} chunkId * @param {any} cause */ constructor(projectId, chunkId, cause) { @@ -58,8 +58,8 @@ class StoreError extends OError { } /** - * @param {number|string} projectId - * @param {number|string} chunkId + * @param {string} projectId + * @param {string} chunkId * @return {string} */ function getKey(projectId, chunkId) { @@ -89,8 +89,8 @@ class HistoryStore { /** * Load the raw object for a History. * - * @param {number|string} projectId - * @param {number|string} chunkId + * @param {string} projectId + * @param {string} chunkId * @return {Promise} */ async loadRaw(projectId, chunkId) { @@ -144,8 +144,8 @@ class HistoryStore { /** * Compress and store a {@link History}. * - * @param {number|string} projectId - * @param {number|string} chunkId + * @param {string} projectId + * @param {string} chunkId * @param {import('overleaf-editor-core/lib/types').RawHistory} rawHistory */ async storeRaw(projectId, chunkId, rawHistory) { diff --git a/services/history-v1/storage/lib/knex.js b/services/history-v1/storage/lib/knex.js index 5cdc85e2ab..7000fe034c 100644 --- a/services/history-v1/storage/lib/knex.js +++ b/services/history-v1/storage/lib/knex.js @@ -1,6 +1,8 @@ +// @ts-check + 'use strict' const env = process.env.NODE_ENV || 'development' const knexfile = require('../../knexfile') -module.exports = require('knex')(knexfile[env]) +module.exports = require('knex').default(knexfile[env]) diff --git a/services/history-v1/storage/lib/redis.js b/services/history-v1/storage/lib/redis.js new file mode 100644 index 0000000000..9b00cc0a26 --- /dev/null +++ b/services/history-v1/storage/lib/redis.js @@ -0,0 +1,19 @@ +const config = require('config') +const redis = require('@overleaf/redis-wrapper') + +const historyRedisOptions = config.get('redis.history') +const rclientHistory = redis.createClient(historyRedisOptions) + +const lockRedisOptions = config.get('redis.history') +const rclientLock = redis.createClient(lockRedisOptions) + +async function disconnect() { + await Promise.all([rclientHistory.disconnect(), rclientLock.disconnect()]) +} + +module.exports = { + rclientHistory, + rclientLock, + redis, + disconnect, +} diff --git a/services/history-v1/storage/lib/scan.js b/services/history-v1/storage/lib/scan.js new file mode 100644 index 0000000000..45d0c327fe --- /dev/null +++ b/services/history-v1/storage/lib/scan.js @@ -0,0 +1,52 @@ +const BATCH_SIZE = 1000 // Default batch size for SCAN + +/** + * Asynchronously scans a Redis instance or cluster for keys matching a pattern. + * + * This function handles both standalone Redis instances and Redis clusters. + * For clusters, it iterates over all master nodes. It yields keys in batches + * as they are found by the SCAN command. + * + * @param {object} redisClient - The Redis client instance (from @overleaf/redis-wrapper). + * @param {string} pattern - The pattern to match keys against (e.g., 'user:*'). + * @param {number} [count=BATCH_SIZE] - Optional hint for Redis SCAN count per iteration. + * @yields {string[]} A batch of matching keys. + */ +async function* scanRedisCluster(redisClient, pattern, count = BATCH_SIZE) { + const nodes = redisClient.nodes ? redisClient.nodes('master') : [redisClient] + + for (const node of nodes) { + let cursor = '0' + do { + // redisClient from @overleaf/redis-wrapper uses ioredis style commands + const [nextCursor, keys] = await node.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + count + ) + cursor = nextCursor + if (keys.length > 0) { + yield keys + } + } while (cursor !== '0') + } +} + +/** + * Extracts the content within the first pair of curly braces {} from a string. + * This is used to extract a user ID or project ID from a Redis key. + * + * @param {string} key - The input string containing content within curly braces. + * @returns {string | null} The extracted content (the key ID) if found, otherwise null. + */ +function extractKeyId(key) { + const match = key.match(/\{(.*?)\}/) + if (match && match[1]) { + return match[1] + } + return null +} + +module.exports = { scanRedisCluster, extractKeyId } diff --git a/services/history-v1/storage/scripts/backup.mjs b/services/history-v1/storage/scripts/backup.mjs index 474192dc74..9ae6101105 100644 --- a/services/history-v1/storage/scripts/backup.mjs +++ b/services/history-v1/storage/scripts/backup.mjs @@ -9,6 +9,7 @@ import { create, } from '../lib/chunk_store/index.js' import { client } from '../lib/mongodb.js' +import redis from '../lib/redis.js' import knex from '../lib/knex.js' import { historyStore } from '../lib/history_store.js' import pLimit from 'p-limit' @@ -1091,5 +1092,13 @@ if (import.meta.url === `file://${process.argv[1]}`) { .catch(err => { console.error('Error closing MongoDB connection:', err) }) + redis + .disconnect() + .then(() => { + console.log('Redis connection closed') + }) + .catch(err => { + console.error('Error closing Redis connection:', err) + }) }) } diff --git a/services/history-v1/storage/scripts/backup_blob.mjs b/services/history-v1/storage/scripts/backup_blob.mjs index 2a777d0074..314b05313e 100644 --- a/services/history-v1/storage/scripts/backup_blob.mjs +++ b/services/history-v1/storage/scripts/backup_blob.mjs @@ -10,6 +10,7 @@ import { import assert from '../lib/assert.js' import knex from '../lib/knex.js' import { client } from '../lib/mongodb.js' +import redis from '../lib/redis.js' import { setTimeout } from 'node:timers/promises' import fs from 'node:fs' @@ -23,6 +24,7 @@ async function gracefulShutdown() { console.log('Gracefully shutting down') await knex.destroy() await client.close() + await redis.disconnect() await setTimeout(100) process.exit() } diff --git a/services/history-v1/storage/scripts/backup_scheduler.mjs b/services/history-v1/storage/scripts/backup_scheduler.mjs index 164512701e..3fac053f12 100644 --- a/services/history-v1/storage/scripts/backup_scheduler.mjs +++ b/services/history-v1/storage/scripts/backup_scheduler.mjs @@ -240,17 +240,25 @@ async function processPendingProjects( changeTimes.push(pendingAt) const pendingAge = Math.floor((Date.now() - pendingAt.getTime()) / 1000) if (pendingAge > WARN_THRESHOLD) { - const backupStatus = await getBackupStatus(projectId) - logger.warn( - { - projectId, - pendingAt, - pendingAge, - backupStatus, - warnThreshold: WARN_THRESHOLD, - }, - `pending change exceeds rpo warning threshold` - ) + try { + const backupStatus = await getBackupStatus(projectId) + logger.warn( + { + projectId, + pendingAt, + pendingAge, + backupStatus, + warnThreshold: WARN_THRESHOLD, + }, + `pending change exceeds rpo warning threshold` + ) + } catch (err) { + logger.error( + { projectId, pendingAt, pendingAge }, + 'Error getting backup status' + ) + throw err + } } } if (showOnly && verbose) { @@ -290,10 +298,11 @@ async function processPendingProjects( ) } } - - const oldestChange = changeTimes.reduce((min, time) => - time < min ? time : min - ) + // Set oldestChange to undefined if there are no changes + const oldestChange = + changeTimes.length > 0 + ? changeTimes.reduce((min, time) => (time < min ? time : min)) + : undefined if (showOnly) { console.log( @@ -303,7 +312,9 @@ async function processPendingProjects( console.log(`Found ${count} projects with pending changes:`) console.log(` ${addedCount} jobs added to queue`) console.log(` ${existingCount} jobs already existed in queue`) - console.log(` Oldest pending change: ${formatPendingTime(oldestChange)}`) + if (oldestChange) { + console.log(` Oldest pending change: ${formatPendingTime(oldestChange)}`) + } } } diff --git a/services/history-v1/storage/scripts/expire_redis_chunks.js b/services/history-v1/storage/scripts/expire_redis_chunks.js new file mode 100644 index 0000000000..11b34101da --- /dev/null +++ b/services/history-v1/storage/scripts/expire_redis_chunks.js @@ -0,0 +1,98 @@ +const logger = require('@overleaf/logger') +const commandLineArgs = require('command-line-args') // Add this line +const redis = require('../lib/redis') +const { scanRedisCluster, extractKeyId } = require('../lib/scan') +const { expireCurrentChunk } = require('../lib/chunk_store/redis') + +const rclient = redis.rclientHistory +const EXPIRE_TIME_KEY_PATTERN = `expire-time:{*}` + +const optionDefinitions = [{ name: 'dry-run', alias: 'd', type: Boolean }] +const options = commandLineArgs(optionDefinitions) +const DRY_RUN = options['dry-run'] || false + +logger.initialize('expire-redis-chunks') + +function isExpiredKey(expireTimestamp, currentTime) { + const expireTime = parseInt(expireTimestamp, 10) + if (isNaN(expireTime)) { + return false + } + logger.debug( + { + expireTime, + currentTime, + expireIn: expireTime - currentTime, + expired: currentTime > expireTime, + }, + 'Checking if key is expired' + ) + return currentTime > expireTime +} + +async function processKeysBatch(keysBatch, rclient) { + let clearedKeyCount = 0 + if (keysBatch.length === 0) { + return 0 + } + // For efficiency, we use MGET to fetch all the timestamps in a single request + const expireTimestamps = await rclient.mget(keysBatch) + const currentTime = Date.now() + for (let i = 0; i < keysBatch.length; i++) { + const key = keysBatch[i] + // For each key, do a quick check to see if the key is expired before calling + // the LUA script to expire the chunk atomically. + if (isExpiredKey(expireTimestamps[i], currentTime)) { + const projectId = extractKeyId(key) + if (DRY_RUN) { + logger.info({ projectId }, '[Dry Run] Would expire chunk for project') + } else { + await expireCurrentChunk(projectId) + } + clearedKeyCount++ + } + } + return clearedKeyCount +} + +async function expireRedisChunks() { + let scannedKeyCount = 0 + let clearedKeyCount = 0 + const START_TIME = Date.now() + + if (DRY_RUN) { + // Use global DRY_RUN + logger.info({}, 'starting expireRedisChunks scan in DRY RUN mode') + } else { + logger.info({}, 'starting expireRedisChunks scan') + } + + for await (const keysBatch of scanRedisCluster( + rclient, + EXPIRE_TIME_KEY_PATTERN + )) { + scannedKeyCount += keysBatch.length + clearedKeyCount += await processKeysBatch(keysBatch, rclient) + if (scannedKeyCount % 1000 === 0) { + logger.info( + { scannedKeyCount, clearedKeyCount }, + 'expireRedisChunks scan progress' + ) + } + } + logger.info( + { + scannedKeyCount, + clearedKeyCount, + elapsedTimeInSeconds: Math.floor((Date.now() - START_TIME) / 1000), + dryRun: DRY_RUN, + }, + 'expireRedisChunks scan complete' + ) + await redis.disconnect() +} + +expireRedisChunks().catch(err => { + logger.fatal({ err }, 'unhandled error in expireRedisChunks') + process.exit(1) +}) diff --git a/services/history-v1/storage/scripts/list_redis_buffer_stats.js b/services/history-v1/storage/scripts/list_redis_buffer_stats.js new file mode 100644 index 0000000000..a53a939e44 --- /dev/null +++ b/services/history-v1/storage/scripts/list_redis_buffer_stats.js @@ -0,0 +1,145 @@ +const { rclientHistory, disconnect } = require('../lib/redis') +const { scanRedisCluster } = require('../lib/scan') + +// Lua script to get snapshot length, change lengths, and change timestamps +// Assumes snapshot key is a string and changes key is a list. +const LUA_SCRIPT = ` + -- local cjson = require('cjson') + local snapshotKey = KEYS[1] + local changesKey = KEYS[2] + + -- Get snapshot length (returns 0 if key does not exist) + local snapshotLen = redis.call('STRLEN', snapshotKey) + + -- Return nil if snapshot is empty + if snapshotLen == 0 then + return nil + end + + local changeLengths = {} + local changeTimestamps = {} + + -- Get all changes (returns empty list if key does not exist) + local changes = redis.call('LRANGE', changesKey, 0, -1) + + -- FIXME: it would be better to send all the changes back and do the processing + -- in JS to avoid blocking redis, if we need to run this script regularly + for i, change in ipairs(changes) do + -- Calculate length + table.insert(changeLengths, string.len(change)) + + -- Attempt to decode JSON and extract timestamp + local ok, decoded = pcall(cjson.decode, change) + if ok and type(decoded) == 'table' and decoded.timestamp then + table.insert(changeTimestamps, decoded.timestamp) + else + -- Handle cases where decoding fails or timestamp is missing + -- Log or insert a placeholder like nil if needed, otherwise skip + table.insert(changeTimestamps, nil) -- Keep placeholder for consistency + end + end + + -- Return snapshot length, list of change lengths, and list of change timestamps + return {snapshotLen, changeLengths, changeTimestamps} +` + +// Define the command if it doesn't exist +if (!rclientHistory.getProjectBufferStats) { + rclientHistory.defineCommand('getProjectBufferStats', { + numberOfKeys: 2, + lua: LUA_SCRIPT, + }) +} + +/** + * Processes a single project ID: fetches its buffer stats from Redis + * and writes the results to the output stream in CSV format. + * + * @param {string} projectId The project ID to process. + * @param {WritableStream} outputStream The stream to write CSV output to. + */ +async function processProject(projectId, outputStream) { + try { + // Get current time in milliseconds *before* fetching data + const nowMs = Date.now() + + // Execute the Lua script + const result = await rclientHistory.getProjectBufferStats( + `snapshot:${projectId}`, + `changes:${projectId}` + ) + + // Check if the result is null (e.g., snapshot is empty) + if (result === null) { + console.log( + `Skipping project ${projectId}: Snapshot is empty or does not exist.` + ) + return + } + + const [snapshotSize, changeSizes, changeTimestamps] = result + + // Output snapshot size + outputStream.write(`${projectId},snapshotSize,${snapshotSize}\n`) + outputStream.write(`${projectId},changeCount,${changeSizes.length}\n`) + + const changes = changeSizes.map((size, index) => [ + size, + changeTimestamps[index], + ]) + + let totalChangeSize = 0 + // Output change sizes + for (const [changeSize, changeTimestamp] of changes) { + totalChangeSize += parseInt(changeSize, 10) + const age = nowMs - new Date(changeTimestamp) + const ageInSeconds = Math.floor(age / 1000) + outputStream.write(`${projectId},change,${changeSize},${ageInSeconds}\n`) + } + outputStream.write(`${projectId},totalChangeSize,${totalChangeSize}\n`) + } catch (err) { + // Log error for this specific project but continue with others + console.error(`Error processing project ${projectId}:`, err) + } +} + +async function main() { + const outputStream = process.stdout + + // Write CSV header + outputStream.write('projectId,type,size,age\n') + + try { + const scanPattern = 'snapshot:*' + console.log(`Scanning Redis for keys matching "${scanPattern}"...`) + + for await (const keysBatch of scanRedisCluster( + rclientHistory, + scanPattern + )) { + for (const key of keysBatch) { + const parts = key.split(':') + if (parts.length !== 2 || parts[0] !== 'snapshot') { + console.warn(`Skipping malformed key: ${key}`) + continue + } + const projectId = parts[1] + + // Call processProject directly and await it sequentially + await processProject(projectId, outputStream) + } + } + + console.log('Finished processing keys.') + } catch (error) { + console.error('Error during Redis scan:', error) + } finally { + await disconnect() + console.log('Redis connections closed.') + } +} + +main().catch(err => { + console.error('Unhandled error in main:', err) + process.exit(1) +}) diff --git a/services/history-v1/storage/scripts/redis.mjs b/services/history-v1/storage/scripts/redis.mjs index bf46452ba4..ce9a39891f 100644 --- a/services/history-v1/storage/scripts/redis.mjs +++ b/services/history-v1/storage/scripts/redis.mjs @@ -1,11 +1,28 @@ import redis from '@overleaf/redis-wrapper' import config from 'config' -const redisOptions = config.get('redis.queue') +// Get allowed Redis dbs from config +const redisConfig = config.get('redis') +const allowedDbs = Object.keys(redisConfig) +// Get the Redis db from command line argument or use the first available db as default +const db = process.argv[2] + +// Validate redis db +if (!allowedDbs.includes(db)) { + if (db) { + console.error('Invalid redis db:', db) + } + console.error(`Usage: node redis.mjs [${allowedDbs.join('|')}]`) + process.exit(1) +} + +// Get redis options based on command line argument +const redisOptions = config.get(`redis.${db}`) +console.log('Using redis db:', db) console.log('REDIS CONFIG', { ...redisOptions, - password: '*'.repeat(redisOptions.password.length), + password: '*'.repeat(redisOptions.password?.length), }) const rclient = redis.createClient(redisOptions) diff --git a/services/history-v1/storage/scripts/show.mjs b/services/history-v1/storage/scripts/show.mjs index 04ec6c61a8..b4ae1664e3 100644 --- a/services/history-v1/storage/scripts/show.mjs +++ b/services/history-v1/storage/scripts/show.mjs @@ -6,6 +6,7 @@ import { } from '../lib/chunk_store/index.js' import { client } from '../lib/mongodb.js' import knex from '../lib/knex.js' +import redis from '../lib/redis.js' import { loadGlobalBlobs, BlobStore, @@ -247,4 +248,7 @@ main() .finally(() => { knex.destroy().catch(err => console.error('Error closing Postgres:', err)) client.close().catch(err => console.error('Error closing MongoDB:', err)) + redis + .disconnect() + .catch(err => console.error('Error disconnecting Redis:', err)) }) diff --git a/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs b/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs index f4778c382f..257238aad4 100644 --- a/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs +++ b/services/history-v1/storage/scripts/verify_backed_up_blobs.mjs @@ -16,6 +16,7 @@ import { db, client, } from '../lib/mongodb.js' +import redis from '../lib/redis.js' import commandLineArgs from 'command-line-args' import fs from 'node:fs' @@ -146,4 +147,7 @@ main() console.error('Error closing Postgres connection:', err) }) client.close().catch(err => console.error('Error closing MongoDB:', err)) + redis.disconnect().catch(err => { + console.error('Error disconnecting Redis:', err) + }) }) diff --git a/services/history-v1/storage/scripts/verify_project.mjs b/services/history-v1/storage/scripts/verify_project.mjs index 6e1cb9de89..3c26f9b5da 100644 --- a/services/history-v1/storage/scripts/verify_project.mjs +++ b/services/history-v1/storage/scripts/verify_project.mjs @@ -2,6 +2,7 @@ import commandLineArgs from 'command-line-args' import { verifyProjectWithErrorContext } from '../lib/backupVerifier.mjs' import knex from '../lib/knex.js' import { client } from '../lib/mongodb.js' +import redis from '../lib/redis.js' import { setTimeout } from 'node:timers/promises' import { loadGlobalBlobs } from '../lib/blob_store/index.js' @@ -10,6 +11,7 @@ const { historyId } = commandLineArgs([{ name: 'historyId', type: String }]) async function gracefulShutdown(code = process.exitCode) { await knex.destroy() await client.close() + await redis.disconnect() await setTimeout(1_000) process.exit(code) } diff --git a/services/history-v1/storage/scripts/verify_sampled_projects.mjs b/services/history-v1/storage/scripts/verify_sampled_projects.mjs index e5b2d0c347..a74a8b9798 100644 --- a/services/history-v1/storage/scripts/verify_sampled_projects.mjs +++ b/services/history-v1/storage/scripts/verify_sampled_projects.mjs @@ -14,6 +14,7 @@ import { loadGlobalBlobs } from '../lib/blob_store/index.js' import { getDatesBeforeRPO } from '../../backupVerifier/utils.mjs' import { EventEmitter } from 'node:events' import { mongodb } from '../index.js' +import redis from '../lib/redis.js' logger.logger.level('fatal') @@ -30,6 +31,7 @@ const usageMessage = [ async function gracefulShutdown(code = process.exitCode) { await knex.destroy() await client.close() + await redis.disconnect() await setTimeout(1_000) process.exit(code) } diff --git a/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs b/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs index fe3a4d1591..2b4001a9f0 100644 --- a/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs +++ b/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs @@ -30,14 +30,17 @@ import { historyStore } from '../../../../storage/lib/history_store.js' * @typedef {import("overleaf-editor-core").Blob} Blob */ -async function verifyProjectScript(historyId) { +// Timeout for script execution, increased to avoid flaky tests +const SCRIPT_TIMEOUT = 15_000 + +async function verifyProjectScript(historyId, expectFail = true) { try { const result = await promisify(execFile)( process.argv0, ['storage/scripts/verify_project.mjs', `--historyId=${historyId}`], { encoding: 'utf-8', - timeout: 5_000, + timeout: SCRIPT_TIMEOUT, env: { ...process.env, LOG_LEVEL: 'warn', @@ -53,6 +56,9 @@ async function verifyProjectScript(historyId) { 'code' in err && 'stderr' in err ) { + if (!expectFail) { + console.log(err) + } return { stdout: typeof err.stdout === 'string' ? err.stdout : '', status: typeof err.code === 'number' ? err.code : -1, @@ -68,7 +74,7 @@ async function verifyProjectScript(historyId) { * @param {string} hash * @return {Promise<{stdout: string, status:number }>} */ -async function verifyBlobScript(historyId, hash) { +async function verifyBlobScript(historyId, hash, expectFail = true) { try { const result = await promisify(execFile)( process.argv0, @@ -79,7 +85,7 @@ async function verifyBlobScript(historyId, hash) { ], { encoding: 'utf-8', - timeout: 5_000, + timeout: SCRIPT_TIMEOUT, env: { ...process.env, LOG_LEVEL: 'warn', @@ -89,6 +95,9 @@ async function verifyBlobScript(historyId, hash) { return { status: 0, stdout: result.stdout } } catch (err) { if (err && typeof err === 'object' && 'stdout' in err && 'code' in err) { + if (!expectFail) { + console.log(err) + } return { stdout: typeof err.stdout === 'string' ? err.stdout : '', status: typeof err.code === 'number' ? err.code : -1, @@ -202,6 +211,7 @@ async function checkDEKExists(historyId) { } describe('backupVerifier', function () { + this.timeout(5_000 + SCRIPT_TIMEOUT) // allow time for external scripts to run const historyIdPostgres = '42' const historyIdMongo = '000000000000000000000042' let blobHashPG, blobHashMongo, blobPathPG @@ -228,7 +238,7 @@ describe('backupVerifier', function () { describe('storage/scripts/verify_project.mjs', function () { describe('when the project is appropriately backed up', function () { it('should return 0', async function () { - const response = await verifyProjectScript(historyIdPostgres) + const response = await verifyProjectScript(historyIdPostgres, false) expect(response.status).to.equal(0) }) }) @@ -306,12 +316,20 @@ describe('backupVerifier', function () { expect(result.stdout).to.include('hash mismatch for backed up blob') }) it('should successfully verify from postgres', async function () { - const result = await verifyBlobScript(historyIdPostgres, blobHashPG) + const result = await verifyBlobScript( + historyIdPostgres, + blobHashPG, + false + ) expect(result.status).to.equal(0) expect(result.stdout.split('\n')).to.include('OK') }) it('should successfully verify from mongo', async function () { - const result = await verifyBlobScript(historyIdMongo, blobHashMongo) + const result = await verifyBlobScript( + historyIdMongo, + blobHashMongo, + false + ) expect(result.status).to.equal(0) expect(result.stdout.split('\n')).to.include('OK') }) diff --git a/services/history-v1/test/acceptance/js/api/project_updates.test.js b/services/history-v1/test/acceptance/js/api/project_updates.test.js index d67000245a..eb7b1703a7 100644 --- a/services/history-v1/test/acceptance/js/api/project_updates.test.js +++ b/services/history-v1/test/acceptance/js/api/project_updates.test.js @@ -22,6 +22,7 @@ const TextOperation = core.TextOperation const V2DocVersions = core.V2DocVersions const knex = require('../../../../storage').knex +const redis = require('../../../../storage/lib/chunk_store/redis') describe('history import', function () { beforeEach(cleanup.everything) @@ -580,7 +581,7 @@ describe('history import', function () { .catch(expectResponse.unprocessableEntity) .then(getLatestContent) .then(response => { - // Check that no chaes were made + // Check that no changes were made const snapshot = Snapshot.fromRaw(response.obj) expect(snapshot.countFiles()).to.equal(1) expect(snapshot.getFile(mainFilePathname).getHash()).to.equal( @@ -594,6 +595,10 @@ describe('history import', function () { testFiles.NULL_CHARACTERS_TXT_BYTE_LENGTH ) }) + .then(() => { + // Now clear the cache because we have changed the string length in the database + return redis.clearCache(testProjectId) + }) .then(importChanges) .then(getLatestContent) .then(response => { diff --git a/services/history-v1/test/acceptance/js/api/projects.test.js b/services/history-v1/test/acceptance/js/api/projects.test.js index 7af8b0172f..3c333d8698 100644 --- a/services/history-v1/test/acceptance/js/api/projects.test.js +++ b/services/history-v1/test/acceptance/js/api/projects.test.js @@ -21,6 +21,8 @@ const { Snapshot, Change, AddFileOperation, + EditFileOperation, + TextOperation, } = require('overleaf-editor-core') const testProjects = require('./support/test_projects') @@ -103,56 +105,176 @@ describe('project controller', function () { // https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862 }) - describe('getLatestHashedContent', function () { - let limitsToPersistImmediately + describe('project with changes', function () { + let projectId - before(function () { + beforeEach(async function () { // used to provide a limit which forces us to persist all of the changes. const farFuture = new Date() farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) - limitsToPersistImmediately = { + const limits = { minChangeTimestamp: farFuture, maxChangeTimestamp: farFuture, } - }) - - it('returns a snaphot', async function () { const changes = [ new Change( [new AddFileOperation('test.tex', File.fromString('ab'))], new Date(), [] ), + new Change( + [new AddFileOperation('other.tex', File.fromString('hello'))], + new Date(), + [] + ), ] - const projectId = await createEmptyProject() - await persistChanges(projectId, changes, limitsToPersistImmediately, 0) - const response = - await testServer.basicAuthClient.apis.Project.getLatestHashedContent({ - project_id: projectId, - }) - expect(response.status).to.equal(HTTPStatus.OK) - const snapshot = Snapshot.fromRaw(response.obj) - expect(snapshot.countFiles()).to.equal(1) - expect(snapshot.getFile('test.tex').getHash()).to.equal( - testFiles.STRING_AB_HASH - ) + projectId = await createEmptyProject() + await persistChanges(projectId, changes, limits, 0) }) - describe('getLatestHistoryRaw', function () { - it('should handles read', async function () { - const projectId = fixtures.docs.initializedProject.id + + describe('getLatestHashedContent', function () { + it('returns a snapshot', async function () { const response = - await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistoryRaw( - { - project_id: projectId, - readOnly: 'true', - } + await testServer.basicAuthClient.apis.Project.getLatestHashedContent({ + project_id: projectId, + }) + expect(response.status).to.equal(HTTPStatus.OK) + const snapshot = Snapshot.fromRaw(response.obj) + expect(snapshot.countFiles()).to.equal(2) + expect(snapshot.getFile('test.tex').getHash()).to.equal( + testFiles.STRING_AB_HASH + ) + }) + }) + + describe('getChanges', function () { + it('returns all changes when not given a limit', async function () { + const response = + await testServer.basicAuthClient.apis.Project.getChanges({ + project_id: projectId, + }) + expect(response.status).to.equal(HTTPStatus.OK) + const changes = response.obj + expect(changes.length).to.equal(2) + const filenames = changes + .flatMap(change => change.operations) + .map(operation => operation.pathname) + expect(filenames).to.deep.equal(['test.tex', 'other.tex']) + }) + + it('returns only requested changes', async function () { + const response = + await testServer.basicAuthClient.apis.Project.getChanges({ + project_id: projectId, + since: 1, + }) + expect(response.status).to.equal(HTTPStatus.OK) + const changes = response.obj + expect(changes.length).to.equal(1) + const filenames = changes + .flatMap(change => change.operations) + .map(operation => operation.pathname) + expect(filenames).to.deep.equal(['other.tex']) + }) + + it('rejects negative versions', async function () { + await expect( + testServer.basicAuthClient.apis.Project.getChanges({ + project_id: projectId, + since: -1, + }) + ).to.be.rejectedWith('Bad Request') + }) + + it('rejects out of bounds versions', async function () { + await expect( + testServer.basicAuthClient.apis.Project.getChanges({ + project_id: projectId, + since: 20, + }) + ).to.be.rejectedWith('Bad Request') + }) + }) + }) + + describe('project with many chunks', function () { + let projectId + + beforeEach(async function () { + // used to provide a limit which forces us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + const limits = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChunkChanges: 5, + } + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString(''))], + new Date(), + [] + ), + ] + + for (let i = 0; i < 20; i++) { + const textOperation = new TextOperation() + textOperation.retain(i) + textOperation.insert('x') + changes.push( + new Change( + [new EditFileOperation('test.tex', textOperation)], + new Date(), + [] ) - expect(response.body).to.deep.equal({ - startVersion: 0, - endVersion: 1, - endTimestamp: '2032-01-01T00:00:00.000Z', - }) + ) + } + + projectId = await createEmptyProject() + await persistChanges(projectId, changes, limits, 0) + }) + + it('returns all changes when not given a limit', async function () { + const response = await testServer.basicAuthClient.apis.Project.getChanges( + { + project_id: projectId, + } + ) + expect(response.status).to.equal(HTTPStatus.OK) + const changes = response.obj + expect(changes.length).to.equal(21) + expect(changes[10].operations[0].textOperation).to.deep.equal([9, 'x']) + }) + + it('returns only requested changes', async function () { + const response = await testServer.basicAuthClient.apis.Project.getChanges( + { + project_id: projectId, + since: 10, + } + ) + expect(response.status).to.equal(HTTPStatus.OK) + const changes = response.obj + expect(changes.length).to.equal(11) + expect(changes[2].operations[0].textOperation).to.deep.equal([11, 'x']) + }) + }) + + describe('getLatestHistoryRaw', function () { + it('should handles read', async function () { + const projectId = fixtures.docs.initializedProject.id + const response = + await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistoryRaw( + { + project_id: projectId, + readOnly: 'true', + } + ) + expect(response.body).to.deep.equal({ + startVersion: 0, + endVersion: 1, + endTimestamp: '2032-01-01T00:00:00.000Z', }) }) }) diff --git a/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs b/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs index 10d6dbc6c1..57a805e334 100644 --- a/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs +++ b/services/history-v1/test/acceptance/js/api/support/test_backup_verifier_server.mjs @@ -26,7 +26,7 @@ async function listenOnRandomPort() { return } catch {} } - server = await startApp(0) + server = await startApp(0, false) } after('close server', function (done) { diff --git a/services/history-v1/test/acceptance/js/storage/assert.test.js b/services/history-v1/test/acceptance/js/storage/assert.test.js new file mode 100644 index 0000000000..6ba30e2562 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/assert.test.js @@ -0,0 +1,248 @@ +'use strict' + +const OError = require('@overleaf/o-error') +const { expect } = require('chai') +const assert = require('../../../../storage/lib/assert') + +describe('assert', function () { + describe('blobHash', function () { + it('should not throw for valid blob hashes', function () { + expect(() => + assert.blobHash( + 'aad321caf77ca6c5ab09e6c638c237705f93b001', + 'should be a blob hash' + ) + ).to.not.throw() + }) + + it('should throw for invalid blob hashes', function () { + try { + assert.blobHash('invalid-hash', 'should be a blob hash') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a blob hash') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-hash' }) + } + }) + + it('should throw for string integer blob hashes', function () { + try { + assert.blobHash('123', 'should be a blob hash') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a blob hash') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: '123' }) + } + }) + }) + + describe('projectId', function () { + it('should not throw for valid mongo project ids', function () { + expect(() => + assert.projectId('507f1f77bcf86cd799439011', 'should be a project id') + ).to.not.throw() + }) + + it('should not throw for valid postgres project ids', function () { + expect(() => + assert.projectId('123456789', 'should be a project id') + ).to.not.throw() + }) + + it('should throw for invalid project ids', function () { + try { + assert.projectId('invalid-id', 'should be a project id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a project id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' }) + } + }) + + it('should throw for non-numeric project ids', function () { + try { + assert.projectId('12345x', 'should be a project id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a project id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345x' }) + } + }) + + it('should throw for postgres ids starting with 0', function () { + try { + assert.projectId('0123456', 'should be a project id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a project id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: '0123456' }) + } + }) + }) + + describe('chunkId', function () { + it('should not throw for valid mongo chunk ids', function () { + expect(() => + assert.chunkId('507f1f77bcf86cd799439011', 'should be a chunk id') + ).to.not.throw() + }) + + it('should not throw for valid postgres chunk ids', function () { + expect(() => + assert.chunkId('123456789', 'should be a chunk id') + ).to.not.throw() + }) + + it('should throw for invalid chunk ids', function () { + try { + assert.chunkId('invalid-id', 'should be a chunk id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a chunk id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' }) + } + }) + + it('should throw for integer chunk ids', function () { + try { + assert.chunkId(12345, 'should be a chunk id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a chunk id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: 12345 }) + } + }) + }) + + describe('mongoId', function () { + it('should not throw for valid mongo ids', function () { + expect(() => + assert.mongoId('507f1f77bcf86cd799439011', 'should be a mongo id') + ).to.not.throw() + }) + + it('should throw for invalid mongo ids', function () { + try { + assert.mongoId('invalid-id', 'should be a mongo id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a mongo id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' }) + } + }) + + it('should throw for numeric mongo ids', function () { + try { + assert.mongoId('12345', 'should be a mongo id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a mongo id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345' }) + } + }) + + it('should throw for mongo ids that are too short', function () { + try { + assert.mongoId('507f1f77bcf86cd79943901', 'should be a mongo id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a mongo id') + expect(OError.getFullInfo(error)).to.deep.equal({ + arg: '507f1f77bcf86cd79943901', + }) + } + }) + + it('should throw for mongo ids that are too long', function () { + try { + assert.mongoId('507f1f77bcf86cd7994390111', 'should be a mongo id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a mongo id') + expect(OError.getFullInfo(error)).to.deep.equal({ + arg: '507f1f77bcf86cd7994390111', + }) + } + }) + }) + + describe('postgresId', function () { + it('should not throw for valid postgres ids', function () { + expect(() => + assert.postgresId('123456789', 'should be a postgres id') + ).to.not.throw() + expect(() => + assert.postgresId('1', 'should be a postgres id') + ).to.not.throw() + }) + + it('should throw for invalid postgres ids', function () { + try { + assert.postgresId('invalid-id', 'should be a postgres id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a postgres id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' }) + } + }) + + it('should throw for postgres ids starting with 0', function () { + try { + assert.postgresId('0123456', 'should be a postgres id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a postgres id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: '0123456' }) + } + }) + + it('should throw for postgres ids that are too long', function () { + try { + assert.postgresId('12345678901', 'should be a postgres id') + expect.fail() + } catch (error) { + expect(error).to.be.instanceOf(TypeError) + expect(error.message).to.equal('should be a postgres id') + expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345678901' }) + } + }) + }) + + describe('regex constants', function () { + it('MONGO_ID_REGEXP should match valid mongo ids', function () { + expect('507f1f77bcf86cd799439011').to.match(assert.MONGO_ID_REGEXP) + expect('abcdef0123456789abcdef01').to.match(assert.MONGO_ID_REGEXP) + }) + + it('MONGO_ID_REGEXP should not match invalid mongo ids', function () { + expect('invalid-id').to.not.match(assert.MONGO_ID_REGEXP) + expect('507f1f77bcf86cd79943901').to.not.match(assert.MONGO_ID_REGEXP) // too short + expect('507f1f77bcf86cd7994390111').to.not.match(assert.MONGO_ID_REGEXP) // too long + expect('507F1F77BCF86CD799439011').to.not.match(assert.MONGO_ID_REGEXP) // uppercase + }) + + it('POSTGRES_ID_REGEXP should match valid postgres ids', function () { + expect('123456789').to.match(assert.POSTGRES_ID_REGEXP) + expect('1').to.match(assert.POSTGRES_ID_REGEXP) + }) + + it('POSTGRES_ID_REGEXP should not match invalid postgres ids', function () { + expect('invalid-id').to.not.match(assert.POSTGRES_ID_REGEXP) + expect('0123456').to.not.match(assert.POSTGRES_ID_REGEXP) // starts with 0 + expect('12345678901').to.not.match(assert.POSTGRES_ID_REGEXP) // too long (> 10 digits) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js b/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js index 0add4fa901..e762c33569 100644 --- a/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js +++ b/services/history-v1/test/acceptance/js/storage/blob_store_postgres.test.js @@ -8,20 +8,20 @@ describe('BlobStore postgres backend', function () { const projectId = new ObjectId().toString() await expect( postgresBackend.insertBlob(projectId, 'hash', 123, 99) - ).to.be.rejectedWith(`bad projectId ${projectId}`) + ).to.be.rejectedWith('bad projectId') }) it('deleteBlobs rejects when called with bad projectId', async function () { const projectId = new ObjectId().toString() await expect(postgresBackend.deleteBlobs(projectId)).to.be.rejectedWith( - `bad projectId ${projectId}` + 'bad projectId' ) }) it('findBlobs rejects when called with bad projectId', async function () { const projectId = new ObjectId().toString() await expect(postgresBackend.findBlobs(projectId)).to.be.rejectedWith( - `bad projectId ${projectId}` + 'bad projectId' ) }) @@ -29,14 +29,14 @@ describe('BlobStore postgres backend', function () { const projectId = new ObjectId().toString() await expect( postgresBackend.findBlob(projectId, 'hash') - ).to.be.rejectedWith(`bad projectId ${projectId}`) + ).to.be.rejectedWith('bad projectId') }) it('getProjectBlobs rejects when called with bad projectId', async function () { const projectId = new ObjectId().toString() await expect( postgresBackend.getProjectBlobs(projectId) - ).to.be.rejectedWith(`bad projectId ${projectId}`) + ).to.be.rejectedWith('bad projectId') }) }) }) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js b/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js new file mode 100644 index 0000000000..841282a8e4 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js @@ -0,0 +1,351 @@ +'use strict' + +const { expect } = require('chai') +const sinon = require('sinon') +const { + Chunk, + Snapshot, + History, + File, + AddFileOperation, + EditFileOperation, + AddCommentOperation, + TextOperation, + Range, + TrackingProps, + Change, +} = require('overleaf-editor-core') +const cleanup = require('./support/cleanup') +const fixtures = require('./support/fixtures') +const chunkBuffer = require('../../../../storage/lib/chunk_buffer') +const chunkStore = require('../../../../storage/lib/chunk_store') +const redisBackend = require('../../../../storage/lib/chunk_store/redis') +const metrics = require('@overleaf/metrics') + +describe('chunk buffer', function () { + beforeEach(cleanup.everything) + beforeEach(fixtures.create) + beforeEach(function () { + sinon.spy(metrics, 'inc') + }) + afterEach(function () { + metrics.inc.restore() + }) + + const projectId = '123456' + + describe('loadLatest', function () { + // Initialize project and create a test chunk + beforeEach(async function () { + // Initialize project in chunk store + await chunkStore.initializeProject(projectId) + }) + + describe('with an existing chunk', function () { + beforeEach(async function () { + // Create a sample chunk with some content + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 1) // startVersion 1 + + // Store the chunk directly in the chunk store using create method + // which internally calls uploadChunk + await chunkStore.create(projectId, chunk) + + // Clear any existing cache + await redisBackend.clearCache(projectId) + }) + + it('should load from chunk store and update cache on first access (cache miss)', async function () { + // Load the underlying chunk from the chunk store for verification + const storedChunk = await chunkStore.loadLatest(projectId) + + // First access should load from chunk store and populate cache + const firstResult = await chunkBuffer.loadLatest(projectId) + + // Verify the chunk is correct + expect(firstResult).to.not.be.null + expect(firstResult.getStartVersion()).to.equal(1) + expect(firstResult.getEndVersion()).to.equal(2) + + // Verify the chunk is the same as the one in the store + expect(firstResult).to.deep.equal(storedChunk) + + // Verify that we got a cache miss metric + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-miss', + }) + ).to.be.true + + // Reset the metrics spy + metrics.inc.resetHistory() + + // Second access should hit the cache + const secondResult = await chunkBuffer.loadLatest(projectId) + + // Verify we got the same chunk + expect(secondResult).to.not.be.null + expect(secondResult.getStartVersion()).to.equal(1) + expect(secondResult.getEndVersion()).to.equal(2) + + // Verify the chunk is the same as the one in the store + expect(secondResult).to.deep.equal(storedChunk) + + // Verify that we got a cache hit metric + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-hit', + }) + ).to.be.true + + // Verify both chunks are equivalent + expect(secondResult.getStartVersion()).to.equal( + firstResult.getStartVersion() + ) + expect(secondResult.getEndVersion()).to.equal( + firstResult.getEndVersion() + ) + expect(secondResult).to.deep.equal(firstResult) + }) + + it('should refresh the cache when chunk changes in the store', async function () { + // First access to load into cache + const firstResult = await chunkBuffer.loadLatest(projectId) + expect(firstResult.getStartVersion()).to.equal(1) + + // Reset metrics spy + metrics.inc.resetHistory() + + // Create a new chunk with different content + const newSnapshot = new Snapshot() + const newChanges = [ + new Change( + [ + new AddFileOperation( + 'updated.tex', + File.fromString('Updated content') + ), + ], + new Date(), + [] + ), + ] + const newHistory = new History(newSnapshot, newChanges) + const newChunk = new Chunk(newHistory, 2) // Different start version + + // Store the new chunk directly in the chunk store + await chunkStore.create(projectId, newChunk) + + // Load the underlying chunk from the chunk store for verification + const storedChunk = await chunkStore.loadLatest(projectId) + + // Access again - should detect the change and refresh cache + const secondResult = await chunkBuffer.loadLatest(projectId) + + // Verify we got the updated chunk + expect(secondResult.getStartVersion()).to.equal(2) + expect(secondResult.getEndVersion()).to.equal(3) + // Verify that the chunk content is the same + expect(secondResult).to.deep.equal(storedChunk) + + // Verify that we got a cache miss metric (since the cached chunk was invalidated) + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-miss', + }) + ).to.be.true + }) + + it('should continue using cache when chunk in store has not changed', async function () { + // Load the underlying chunk from the chunk store for verification + const storedChunk = await chunkStore.loadLatest(projectId) + + // First access to load into cache + await chunkBuffer.loadLatest(projectId) + + // Reset metrics spy + metrics.inc.resetHistory() + + // Access again without changing the underlying chunk + const result = await chunkBuffer.loadLatest(projectId) + + // Verify we got the same chunk + expect(result.getStartVersion()).to.equal(1) + expect(result.getEndVersion()).to.equal(2) + expect(result).to.deep.equal(storedChunk) + + // Verify that we got a cache hit metric + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-hit', + }) + ).to.be.true + }) + }) + + it('should handle a chunk with metadata, comments and tracked changes', async function () { + // Create a snapshot and initial file + const snapshot = new Snapshot() + const initialFileOp = new AddFileOperation( + 'test.tex', + File.fromString('Initial line.\\nSecond line.', { + meta1: 'abc', + meta2: 'def', + }) + ) + const initialChange = new Change([initialFileOp], new Date(), []) + + // Add a comment + const commentOp = new AddCommentOperation( + 'comment1', + [new Range(0, 7)] // Range for "Initial" + ) + const commentChange = new Change( + [new EditFileOperation('test.tex', commentOp)], + new Date(), + [] + ) + + // Tracked insert + const trackedInsertOp = new TextOperation() + .retain(14) + .insert('Hello', { + commentIds: ['comment1'], + tracking: TrackingProps.fromRaw({ + ts: '2024-01-01T00:00:00.000Z', + type: 'insert', + userId: 'user1', + }), + }) + .retain(12) + const insertChange = new Change( + [new EditFileOperation('test.tex', trackedInsertOp)], + new Date(), + [] + ) + + // Tracked delete + const trackedDeleteOp = new TextOperation().retain(14, { + tracking: TrackingProps.fromRaw({ + ts: '2024-01-01T00:00:00.000Z', + type: 'delete', + userId: 'user1', + }), + }) + const deleteChange = new Change( + [new EditFileOperation('test.tex', trackedDeleteOp)], + new Date(), + [] + ) + + // Combine changes into history and create chunk + const history = new History(snapshot, [ + initialChange, + commentChange, + insertChange, + deleteChange, + ]) + const chunk = new Chunk(history, 1) // Start version 0 + // Store the chunk + await chunkStore.create(projectId, chunk) + // Clear the cache + await redisBackend.clearCache(projectId) + metrics.inc.resetHistory() + + // Load the underlying chunk from the chunk store for verification + const storedChunk = await chunkStore.loadLatest(projectId) + + // Load the chunk via buffer (cache miss) + const firstResult = await chunkBuffer.loadLatest(projectId) + + // Verify chunk details + expect(firstResult.getStartVersion()).to.equal(1) + expect(firstResult.getEndVersion()).to.equal(5) // 4 changes + expect(firstResult.history.changes.length).to.equal(4) + expect(firstResult).to.deep.equal(storedChunk) + + // Verify cache miss metric + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-miss', + }) + ).to.be.true + + // Reset metrics + metrics.inc.resetHistory() + + // Second access should hit the cache + const secondResult = await chunkBuffer.loadLatest(projectId) + + // Verify we got the same chunk + expect(secondResult.getStartVersion()).to.equal(1) + expect(secondResult.getEndVersion()).to.equal(5) + expect(secondResult.history.changes.length).to.equal(4) + expect(secondResult).to.deep.equal(storedChunk) + + // Verify cache hit metric + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-hit', + }) + ).to.be.true + }) + + describe('with an empty project', function () { + it('should handle a case with empty chunks (no changes)', async function () { + // Clear the cache + await redisBackend.clearCache(projectId) + + // Load the underlying chunk from the chunk store for verification + const storedChunk = await chunkStore.loadLatest(projectId) + + // Load the initial empty chunk via buffer + const result = await chunkBuffer.loadLatest(projectId) + + // Verify we got the empty chunk + expect(result.getStartVersion()).to.equal(0) + expect(result.getEndVersion()).to.equal(0) // Start equals end for empty chunks + expect(result.history.changes.length).to.equal(0) + + // Verify that the chunk is the same as the one in the store + expect(result).to.deep.equal(storedChunk) + + // Verify cache miss metric + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-miss', + }) + ).to.be.true + + // Reset metrics + metrics.inc.resetHistory() + + // Second access should hit the cache + const secondResult = await chunkBuffer.loadLatest(projectId) + + // Verify we got the same empty chunk + expect(secondResult.getStartVersion()).to.equal(0) + expect(secondResult.getEndVersion()).to.equal(0) + expect(secondResult.history.changes.length).to.equal(0) + + // Verify that the chunk is the same as the one in the store + expect(secondResult).to.deep.equal(storedChunk) + + // Verify cache hit metric + expect( + metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { + status: 'cache-hit', + }) + ).to.be.true + }) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js index 61d80810f1..98cdd2db4d 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_mongo_backend.test.js @@ -1,8 +1,16 @@ const { expect } = require('chai') const { ObjectId } = require('mongodb') -const { Chunk, Snapshot, History } = require('overleaf-editor-core') +const { + Chunk, + Snapshot, + History, + Change, + AddFileOperation, + File, +} = require('overleaf-editor-core') const cleanup = require('./support/cleanup') const backend = require('../../../../storage/lib/chunk_store/mongo') +const { ChunkVersionConflictError } = require('../../../../storage') describe('chunk store Mongo backend', function () { beforeEach(cleanup.everything) @@ -42,11 +50,86 @@ describe('chunk store Mongo backend', function () { expect(oldChunks).to.deep.equal([]) }) }) + + describe('concurrency handling', function () { + it('prevents chunks from being created with the same start version', async function () { + const projectId = new ObjectId().toString() + const chunks = [makeChunk([], 10), makeChunk([], 10)] + + const chunkIds = [] + for (const chunk of chunks) { + const chunkId = await backend.insertPendingChunk(projectId, chunk) + chunkIds.push(chunkId) + } + + await backend.confirmCreate(projectId, chunks[0], chunkIds[0]) + await expect( + backend.confirmCreate(projectId, chunks[1], chunkIds[1]) + ).to.be.rejectedWith(ChunkVersionConflictError) + }) + + describe('conflicts between chunk extension and chunk creation', function () { + let projectId, + baseChunkId, + updatedChunkId, + newChunkId, + updatedChunk, + newChunk + + beforeEach(async function () { + projectId = new ObjectId().toString() + const baseChunk = makeChunk([], 0) + baseChunkId = await backend.insertPendingChunk(projectId, baseChunk) + await backend.confirmCreate(projectId, baseChunk, baseChunkId) + + const change = new Change( + [new AddFileOperation('main.tex', File.fromString('hello'))], + new Date() + ) + + updatedChunk = makeChunk([change], 0) + updatedChunkId = await backend.insertPendingChunk( + projectId, + updatedChunk + ) + newChunk = makeChunk([change], 1) + newChunkId = await backend.insertPendingChunk(projectId, newChunk) + }) + + it('prevents creation after extension', async function () { + await backend.confirmUpdate( + projectId, + baseChunkId, + updatedChunk, + updatedChunkId + ) + await expect( + backend.confirmCreate(projectId, newChunk, newChunkId, { + oldChunkId: baseChunkId, + }) + ).to.be.rejectedWith(ChunkVersionConflictError) + }) + + it('prevents extension after creation', async function () { + await backend.confirmCreate(projectId, newChunk, newChunkId, { + oldChunkId: baseChunkId, + }) + await expect( + backend.confirmUpdate( + projectId, + baseChunkId, + updatedChunk, + updatedChunkId + ) + ).to.be.rejectedWith(ChunkVersionConflictError) + }) + }) + }) }) function makeChunk(changes, versionNumber) { const snapshot = Snapshot.fromRaw({ files: {} }) - const history = new History(snapshot, []) + const history = new History(snapshot, changes) const chunk = new Chunk(history, versionNumber) return chunk } diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js index 20a24de3eb..cd1d705bdc 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_postgres_backend.test.js @@ -1,7 +1,15 @@ const { expect } = require('chai') const { ObjectId } = require('mongodb') -const { Chunk, Snapshot, History } = require('overleaf-editor-core') +const { + Chunk, + Snapshot, + History, + Change, + AddFileOperation, + File, +} = require('overleaf-editor-core') const cleanup = require('./support/cleanup') +const { ChunkVersionConflictError } = require('../../../../storage') const backend = require('../../../../storage/lib/chunk_store/postgres') describe('chunk store Postgres backend', function () { @@ -11,32 +19,86 @@ describe('chunk store Postgres backend', function () { const invalidProjectId = new ObjectId().toString() await expect(backend.getLatestChunk(invalidProjectId)).to.be.rejectedWith( - `bad projectId ${invalidProjectId}` + 'bad projectId' ) await expect( backend.getChunkForVersion(invalidProjectId, 1) - ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`) + ).to.be.rejectedWith('bad projectId') await expect( backend.getChunkForTimestamp(invalidProjectId, new Date()) - ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`) + ).to.be.rejectedWith('bad projectId') await expect( backend.getProjectChunkIds(invalidProjectId) - ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`) + ).to.be.rejectedWith('bad projectId') await expect( backend.insertPendingChunk(invalidProjectId, makeChunk([], 0)) - ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`) + ).to.be.rejectedWith('bad projectId') await expect( backend.confirmCreate(invalidProjectId, makeChunk([], 0), 1) - ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`) + ).to.be.rejectedWith('bad projectId') await expect( backend.confirmUpdate(invalidProjectId, 1, makeChunk([], 0), 2) - ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`) + ).to.be.rejectedWith('bad projectId') await expect(backend.deleteChunk(invalidProjectId, 1)).to.be.rejectedWith( - `bad projectId ${invalidProjectId}` + 'bad projectId' ) await expect( backend.deleteProjectChunks(invalidProjectId) - ).to.be.rejectedWith(`bad projectId ${invalidProjectId}`) + ).to.be.rejectedWith('bad projectId') + }) + + describe('conflicts between chunk extension and chunk creation', function () { + let projectId, + baseChunkId, + updatedChunkId, + newChunkId, + updatedChunk, + newChunk + + beforeEach(async function () { + projectId = '1234' + const baseChunk = makeChunk([], 0) + baseChunkId = await backend.insertPendingChunk(projectId, baseChunk) + await backend.confirmCreate(projectId, baseChunk, baseChunkId) + + const change = new Change( + [new AddFileOperation('main.tex', File.fromString('hello'))], + new Date() + ) + + updatedChunk = makeChunk([change], 0) + updatedChunkId = await backend.insertPendingChunk(projectId, updatedChunk) + newChunk = makeChunk([change], 1) + newChunkId = await backend.insertPendingChunk(projectId, newChunk) + }) + + it('prevents creation after extension', async function () { + await backend.confirmUpdate( + projectId, + baseChunkId, + updatedChunk, + updatedChunkId + ) + await expect( + backend.confirmCreate(projectId, newChunk, newChunkId, { + oldChunkId: baseChunkId, + }) + ).to.be.rejectedWith(ChunkVersionConflictError) + }) + + it('prevents extension after creation', async function () { + await backend.confirmCreate(projectId, newChunk, newChunkId, { + oldChunkId: baseChunkId, + }) + await expect( + backend.confirmUpdate( + projectId, + baseChunkId, + updatedChunk, + updatedChunkId + ) + ).to.be.rejectedWith(ChunkVersionConflictError) + }) }) }) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js new file mode 100644 index 0000000000..612e802ff1 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js @@ -0,0 +1,924 @@ +'use strict' + +const { expect } = require('chai') +const { + Chunk, + Snapshot, + History, + File, + AddFileOperation, + Origin, + Change, + V2DocVersions, +} = require('overleaf-editor-core') +const cleanup = require('./support/cleanup') +const redisBackend = require('../../../../storage/lib/chunk_store/redis') + +describe('chunk store Redis backend', function () { + beforeEach(cleanup.everything) + const projectId = '123456' + + describe('getCurrentChunk', function () { + it('should return null on cache miss', async function () { + const chunk = await redisBackend.getCurrentChunk(projectId) + expect(chunk).to.be.null + }) + + it('should return the cached chunk', async function () { + // Create a sample chunk + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 5) // startVersion 5 + + // Cache the chunk + await redisBackend.setCurrentChunk(projectId, chunk) + + // Retrieve the cached chunk + const cachedChunk = await redisBackend.getCurrentChunk(projectId) + + expect(cachedChunk).to.not.be.null + expect(cachedChunk.getStartVersion()).to.equal(5) + expect(cachedChunk.getEndVersion()).to.equal(6) + expect(cachedChunk).to.deep.equal(chunk) + }) + }) + + describe('setCurrentChunk', function () { + it('should successfully cache a chunk', async function () { + // Create a sample chunk + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 5) // startVersion 5 + + // Cache the chunk + await redisBackend.setCurrentChunk(projectId, chunk) + + // Verify the chunk was cached correctly by retrieving it + const cachedChunk = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunk).to.not.be.null + expect(cachedChunk.getStartVersion()).to.equal(5) + expect(cachedChunk.getEndVersion()).to.equal(6) + expect(cachedChunk).to.deep.equal(chunk) + + // Verify that the chunk was stored correctly using the chunk metadata + const chunkMetadata = + await redisBackend.getCurrentChunkMetadata(projectId) + expect(chunkMetadata).to.not.be.null + expect(chunkMetadata.startVersion).to.equal(5) + expect(chunkMetadata.changesCount).to.equal(1) + }) + + it('should correctly handle a chunk with zero changes', async function () { + // Create a sample chunk with no changes + const snapshot = new Snapshot() + const changes = [] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 10) // startVersion 10 + + // Cache the chunk + await redisBackend.setCurrentChunk(projectId, chunk) + + // Retrieve the cached chunk + const cachedChunk = await redisBackend.getCurrentChunk(projectId) + + expect(cachedChunk).to.not.be.null + expect(cachedChunk.getStartVersion()).to.equal(10) + expect(cachedChunk.getEndVersion()).to.equal(10) // End version should equal start version with no changes + expect(cachedChunk.history.changes.length).to.equal(0) + expect(cachedChunk).to.deep.equal(chunk) + }) + }) + + describe('updating already cached chunks', function () { + it('should replace a chunk with a longer chunk', async function () { + // Set initial chunk with one change + const snapshotA = new Snapshot() + const changesA = [ + new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromString('Initial content') + ), + ], + new Date(), + [] + ), + ] + const historyA = new History(snapshotA, changesA) + const chunkA = new Chunk(historyA, 10) + + await redisBackend.setCurrentChunk(projectId, chunkA) + + // Verify the initial chunk was cached + const cachedChunkA = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkA.getStartVersion()).to.equal(10) + expect(cachedChunkA.getEndVersion()).to.equal(11) + expect(cachedChunkA.history.changes.length).to.equal(1) + + // Create a longer chunk (with more changes) + const snapshotB = new Snapshot() + const changesB = [ + new Change( + [new AddFileOperation('test1.tex', File.fromString('Content 1'))], + new Date(), + [] + ), + new Change( + [new AddFileOperation('test2.tex', File.fromString('Content 2'))], + new Date(), + [] + ), + new Change( + [new AddFileOperation('test3.tex', File.fromString('Content 3'))], + new Date(), + [] + ), + ] + const historyB = new History(snapshotB, changesB) + const chunkB = new Chunk(historyB, 15) + + // Replace the cached chunk + await redisBackend.setCurrentChunk(projectId, chunkB) + + // Verify the new chunk replaced the old one + const cachedChunkB = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkB).to.not.be.null + expect(cachedChunkB.getStartVersion()).to.equal(15) + expect(cachedChunkB.getEndVersion()).to.equal(18) + expect(cachedChunkB.history.changes.length).to.equal(3) + expect(cachedChunkB).to.deep.equal(chunkB) + + // Verify the metadata was updated + const updatedMetadata = + await redisBackend.getCurrentChunkMetadata(projectId) + expect(updatedMetadata.startVersion).to.equal(15) + expect(updatedMetadata.changesCount).to.equal(3) + }) + + it('should replace a chunk with a shorter chunk', async function () { + // Set initial chunk with three changes + const snapshotA = new Snapshot() + const changesA = [ + new Change( + [new AddFileOperation('file1.tex', File.fromString('Content 1'))], + new Date(), + [] + ), + new Change( + [new AddFileOperation('file2.tex', File.fromString('Content 2'))], + new Date(), + [] + ), + new Change( + [new AddFileOperation('file3.tex', File.fromString('Content 3'))], + new Date(), + [] + ), + ] + const historyA = new History(snapshotA, changesA) + const chunkA = new Chunk(historyA, 20) + + await redisBackend.setCurrentChunk(projectId, chunkA) + + // Verify the initial chunk was cached + const cachedChunkA = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkA.getStartVersion()).to.equal(20) + expect(cachedChunkA.getEndVersion()).to.equal(23) + expect(cachedChunkA.history.changes.length).to.equal(3) + + // Create a shorter chunk (with fewer changes) + const snapshotB = new Snapshot() + const changesB = [ + new Change( + [new AddFileOperation('new.tex', File.fromString('New content'))], + new Date(), + [] + ), + ] + const historyB = new History(snapshotB, changesB) + const chunkB = new Chunk(historyB, 30) + + // Replace the cached chunk + await redisBackend.setCurrentChunk(projectId, chunkB) + + // Verify the new chunk replaced the old one + const cachedChunkB = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkB).to.not.be.null + expect(cachedChunkB.getStartVersion()).to.equal(30) + expect(cachedChunkB.getEndVersion()).to.equal(31) + expect(cachedChunkB.history.changes.length).to.equal(1) + expect(cachedChunkB).to.deep.equal(chunkB) + + // Verify the metadata was updated + const updatedMetadata = + await redisBackend.getCurrentChunkMetadata(projectId) + expect(updatedMetadata.startVersion).to.equal(30) + expect(updatedMetadata.changesCount).to.equal(1) + }) + + it('should replace a chunk with a zero-length chunk', async function () { + // Set initial chunk with changes + const snapshotA = new Snapshot() + const changesA = [ + new Change( + [new AddFileOperation('file1.tex', File.fromString('Content 1'))], + new Date(), + [] + ), + new Change( + [new AddFileOperation('file2.tex', File.fromString('Content 2'))], + new Date(), + [] + ), + ] + const historyA = new History(snapshotA, changesA) + const chunkA = new Chunk(historyA, 25) + + await redisBackend.setCurrentChunk(projectId, chunkA) + + // Verify the initial chunk was cached + const cachedChunkA = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkA.getStartVersion()).to.equal(25) + expect(cachedChunkA.getEndVersion()).to.equal(27) + expect(cachedChunkA.history.changes.length).to.equal(2) + + // Create a zero-length chunk (with no changes) + const snapshotB = new Snapshot() + const changesB = [] + const historyB = new History(snapshotB, changesB) + const chunkB = new Chunk(historyB, 40) + + // Replace the cached chunk + await redisBackend.setCurrentChunk(projectId, chunkB) + + // Verify the new chunk replaced the old one + const cachedChunkB = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkB).to.not.be.null + expect(cachedChunkB.getStartVersion()).to.equal(40) + expect(cachedChunkB.getEndVersion()).to.equal(40) // Start version equals end version with no changes + expect(cachedChunkB.history.changes.length).to.equal(0) + expect(cachedChunkB).to.deep.equal(chunkB) + + // Verify the metadata was updated + const updatedMetadata = + await redisBackend.getCurrentChunkMetadata(projectId) + expect(updatedMetadata.startVersion).to.equal(40) + expect(updatedMetadata.changesCount).to.equal(0) + }) + + it('should replace a zero-length chunk with a non-empty chunk', async function () { + // Set initial empty chunk + const snapshotA = new Snapshot() + const changesA = [] + const historyA = new History(snapshotA, changesA) + const chunkA = new Chunk(historyA, 50) + + await redisBackend.setCurrentChunk(projectId, chunkA) + + // Verify the initial chunk was cached + const cachedChunkA = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkA.getStartVersion()).to.equal(50) + expect(cachedChunkA.getEndVersion()).to.equal(50) + expect(cachedChunkA.history.changes.length).to.equal(0) + + // Create a non-empty chunk + const snapshotB = new Snapshot() + const changesB = [ + new Change( + [new AddFileOperation('newfile.tex', File.fromString('New content'))], + new Date(), + [] + ), + new Change( + [ + new AddFileOperation( + 'another.tex', + File.fromString('Another file') + ), + ], + new Date(), + [] + ), + ] + const historyB = new History(snapshotB, changesB) + const chunkB = new Chunk(historyB, 60) + + // Replace the cached chunk + await redisBackend.setCurrentChunk(projectId, chunkB) + + // Verify the new chunk replaced the old one + const cachedChunkB = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunkB).to.not.be.null + expect(cachedChunkB.getStartVersion()).to.equal(60) + expect(cachedChunkB.getEndVersion()).to.equal(62) + expect(cachedChunkB.history.changes.length).to.equal(2) + expect(cachedChunkB).to.deep.equal(chunkB) + + // Verify the metadata was updated + const updatedMetadata = + await redisBackend.getCurrentChunkMetadata(projectId) + expect(updatedMetadata.startVersion).to.equal(60) + expect(updatedMetadata.changesCount).to.equal(2) + }) + }) + + describe('checkCacheValidity', function () { + it('should return true when versions match', function () { + const snapshotA = new Snapshot() + const historyA = new History(snapshotA, []) + const chunkA = new Chunk(historyA, 10) + chunkA.pushChanges([ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello'))], + new Date(), + [] + ), + ]) + + const snapshotB = new Snapshot() + const historyB = new History(snapshotB, []) + const chunkB = new Chunk(historyB, 10) + chunkB.pushChanges([ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello'))], + new Date(), + [] + ), + ]) + + const isValid = redisBackend.checkCacheValidity(chunkA, chunkB) + expect(isValid).to.be.true + }) + + it('should return false when start versions differ', function () { + const snapshotA = new Snapshot() + const historyA = new History(snapshotA, []) + const chunkA = new Chunk(historyA, 10) + + const snapshotB = new Snapshot() + const historyB = new History(snapshotB, []) + const chunkB = new Chunk(historyB, 11) + + const isValid = redisBackend.checkCacheValidity(chunkA, chunkB) + expect(isValid).to.be.false + }) + + it('should return false when end versions differ', function () { + const snapshotA = new Snapshot() + const historyA = new History(snapshotA, []) + const chunkA = new Chunk(historyA, 10) + chunkA.pushChanges([ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello'))], + new Date(), + [] + ), + ]) + + const snapshotB = new Snapshot() + const historyB = new History(snapshotB, []) + const chunkB = new Chunk(historyB, 10) + chunkB.pushChanges([ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello'))], + new Date(), + [] + ), + new Change( + [new AddFileOperation('other.tex', File.fromString('World'))], + new Date(), + [] + ), + ]) + + const isValid = redisBackend.checkCacheValidity(chunkA, chunkB) + expect(isValid).to.be.false + }) + + it('should return false when cached chunk is null', function () { + const snapshotB = new Snapshot() + const historyB = new History(snapshotB, []) + const chunkB = new Chunk(historyB, 10) + + const isValid = redisBackend.checkCacheValidity(null, chunkB) + expect(isValid).to.be.false + }) + }) + + describe('compareChunks', function () { + it('should return true when chunks are identical', function () { + // Create two identical chunks + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date('2025-04-10T12:00:00Z'), // Using fixed date for consistent comparison + [] + ), + ] + const history1 = new History(snapshot, changes) + const chunk1 = new Chunk(history1, 5) + + // Create a separate but identical chunk + const snapshot2 = new Snapshot() + const changes2 = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date('2025-04-10T12:00:00Z'), // Using same fixed date + [] + ), + ] + const history2 = new History(snapshot2, changes2) + const chunk2 = new Chunk(history2, 5) + + const result = redisBackend.compareChunks(projectId, chunk1, chunk2) + expect(result).to.be.true + }) + + it('should return false when chunks differ', function () { + // Create first chunk + const snapshot1 = new Snapshot() + const changes1 = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date('2025-04-10T12:00:00Z'), + [] + ), + ] + const history1 = new History(snapshot1, changes1) + const chunk1 = new Chunk(history1, 5) + + // Create a different chunk (different content) + const snapshot2 = new Snapshot() + const changes2 = [ + new Change( + [ + new AddFileOperation( + 'test.tex', + File.fromString('Different content') + ), + ], + new Date('2025-04-10T12:00:00Z'), + [] + ), + ] + const history2 = new History(snapshot2, changes2) + const chunk2 = new Chunk(history2, 5) + + const result = redisBackend.compareChunks(projectId, chunk1, chunk2) + expect(result).to.be.false + }) + + it('should return false when one chunk is null', function () { + // Create a chunk + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date('2025-04-10T12:00:00Z'), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 5) + + const resultWithNullCached = redisBackend.compareChunks( + projectId, + null, + chunk + ) + expect(resultWithNullCached).to.be.false + + const resultWithNullCurrent = redisBackend.compareChunks( + projectId, + chunk, + null + ) + expect(resultWithNullCurrent).to.be.false + }) + + it('should return false when chunks have different start versions', function () { + // Create first chunk with start version 5 + const snapshot1 = new Snapshot() + const changes1 = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date('2025-04-10T12:00:00Z'), + [] + ), + ] + const history1 = new History(snapshot1, changes1) + const chunk1 = new Chunk(history1, 5) + + // Create second chunk with identical content but different start version (10) + const snapshot2 = new Snapshot() + const changes2 = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello World'))], + new Date('2025-04-10T12:00:00Z'), + [] + ), + ] + const history2 = new History(snapshot2, changes2) + const chunk2 = new Chunk(history2, 10) + + const result = redisBackend.compareChunks(projectId, chunk1, chunk2) + expect(result).to.be.false + }) + }) + + describe('integration with redis', function () { + it('should store and retrieve complex chunks correctly', async function () { + // Create a more complex chunk + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('file1.tex', File.fromString('Content 1'))], + new Date(), + [1234] + ), + new Change( + [new AddFileOperation('file2.tex', File.fromString('Content 2'))], + new Date(), + null, + new Origin('test-origin'), + ['5a296963ad5e82432674c839', null], + '123.4', + new V2DocVersions({ + 'random-doc-id': { pathname: 'file2.tex', v: 123 }, + }) + ), + new Change( + [new AddFileOperation('file3.tex', File.fromString('Content 3'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 20) + + // Cache the chunk + await redisBackend.setCurrentChunk(projectId, chunk) + + // Retrieve the cached chunk + const cachedChunk = await redisBackend.getCurrentChunk(projectId) + + expect(cachedChunk.getStartVersion()).to.equal(20) + expect(cachedChunk.getEndVersion()).to.equal(23) + expect(cachedChunk).to.deep.equal(chunk) + expect(cachedChunk.history.changes.length).to.equal(3) + + // Check that the operations were preserved correctly + const retrievedChanges = cachedChunk.history.changes + expect(retrievedChanges[0].getOperations()[0].getPathname()).to.equal( + 'file1.tex' + ) + expect(retrievedChanges[1].getOperations()[0].getPathname()).to.equal( + 'file2.tex' + ) + expect(retrievedChanges[2].getOperations()[0].getPathname()).to.equal( + 'file3.tex' + ) + + // Check that the chunk was stored correctly using the chunk metadata + const chunkMetadata = + await redisBackend.getCurrentChunkMetadata(projectId) + expect(chunkMetadata).to.not.be.null + expect(chunkMetadata.startVersion).to.equal(20) + expect(chunkMetadata.changesCount).to.equal(3) + }) + }) + + describe('getCurrentChunkIfValid', function () { + it('should return the chunk when versions and changes count match', async function () { + // Create and cache a sample chunk + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Valid content'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 7) // startVersion 7, endVersion 8 + await redisBackend.setCurrentChunk(projectId, chunk) + + // Prepare chunkRecord matching the cached chunk + const chunkRecord = { startVersion: 7, endVersion: 8 } + + // Retrieve using getCurrentChunkIfValid + const validChunk = await redisBackend.getCurrentChunkIfValid( + projectId, + chunkRecord + ) + + expect(validChunk).to.not.be.null + expect(validChunk.getStartVersion()).to.equal(7) + expect(validChunk.getEndVersion()).to.equal(8) + expect(validChunk).to.deep.equal(chunk) + }) + + it('should return null when no chunk is cached', async function () { + // No chunk is cached for this projectId yet + const chunkRecord = { startVersion: 1, endVersion: 2 } + const validChunk = await redisBackend.getCurrentChunkIfValid( + projectId, + chunkRecord + ) + expect(validChunk).to.be.null + }) + + it('should return null when start version mismatches', async function () { + // Cache a chunk with startVersion 10 + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Content'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 10) // startVersion 10, endVersion 11 + await redisBackend.setCurrentChunk(projectId, chunk) + + // Attempt to retrieve with a different startVersion + const chunkRecord = { startVersion: 9, endVersion: 10 } // Incorrect startVersion + const validChunk = await redisBackend.getCurrentChunkIfValid( + projectId, + chunkRecord + ) + expect(validChunk).to.be.null + }) + + it('should return null when changes count mismatches', async function () { + // Cache a chunk with one change (startVersion 15, endVersion 16) + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Content'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 15) + await redisBackend.setCurrentChunk(projectId, chunk) + + // Attempt to retrieve with correct startVersion but incorrect endVersion (implying wrong changes count) + const chunkRecord = { startVersion: 15, endVersion: 17 } // Incorrect endVersion (implies 2 changes) + const validChunk = await redisBackend.getCurrentChunkIfValid( + projectId, + chunkRecord + ) + expect(validChunk).to.be.null + }) + + it('should return the chunk when versions and changes count match for a zero-change chunk', async function () { + // Cache a chunk with zero changes + const snapshot = new Snapshot() + const changes = [] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 20) // startVersion 20, endVersion 20 + await redisBackend.setCurrentChunk(projectId, chunk) + + // Prepare chunkRecord matching the zero-change chunk + const chunkRecord = { startVersion: 20, endVersion: 20 } + + // Retrieve using getCurrentChunkIfValid + const validChunk = await redisBackend.getCurrentChunkIfValid( + projectId, + chunkRecord + ) + + expect(validChunk).to.not.be.null + expect(validChunk.getStartVersion()).to.equal(20) + expect(validChunk.getEndVersion()).to.equal(20) + expect(validChunk.history.changes.length).to.equal(0) + expect(validChunk).to.deep.equal(chunk) + }) + + it('should return null when start version matches but changes count is wrong for zero-change chunk', async function () { + // Cache a chunk with zero changes + const snapshot = new Snapshot() + const changes = [] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 25) // startVersion 25, endVersion 25 + await redisBackend.setCurrentChunk(projectId, chunk) + + // Attempt to retrieve with correct startVersion but incorrect endVersion + const chunkRecord = { startVersion: 25, endVersion: 26 } // Incorrect endVersion (implies 1 change) + const validChunk = await redisBackend.getCurrentChunkIfValid( + projectId, + chunkRecord + ) + expect(validChunk).to.be.null + }) + }) + + describe('getCurrentChunkMetadata', function () { + it('should return metadata for a cached chunk', async function () { + // Cache a chunk + const snapshot = new Snapshot() + const history = new History(snapshot, [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Hello'))], + new Date(), + [] + ), + new Change( + [new AddFileOperation('other.tex', File.fromString('Bonjour'))], + new Date(), + [] + ), + ]) + const chunk = new Chunk(history, 10) + await redisBackend.setCurrentChunk(projectId, chunk) + + const metadata = await redisBackend.getCurrentChunkMetadata(projectId) + expect(metadata).to.deep.equal({ startVersion: 10, changesCount: 2 }) + }) + + it('should return null if no chunk is cached for the project', async function () { + const metadata = await redisBackend.getCurrentChunkMetadata( + 'non-existent-project-id' + ) + expect(metadata).to.be.null + }) + + it('should return metadata with zero changes for a zero-change chunk', async function () { + // Cache a chunk with no changes + const snapshot = new Snapshot() + const history = new History(snapshot, []) + const chunk = new Chunk(history, 5) + await redisBackend.setCurrentChunk(projectId, chunk) + + const metadata = await redisBackend.getCurrentChunkMetadata(projectId) + expect(metadata).to.deep.equal({ startVersion: 5, changesCount: 0 }) + }) + }) + + describe('expireCurrentChunk', function () { + const TEMPORARY_CACHE_LIFETIME_MS = 300 * 1000 // Match the value in redis.js + + it('should return false and not expire a non-expired chunk', async function () { + // Cache a chunk + const snapshot = new Snapshot() + const history = new History(snapshot, []) + const chunk = new Chunk(history, 10) + await redisBackend.setCurrentChunk(projectId, chunk) + + // Attempt to expire immediately (should not be expired yet) + const expired = await redisBackend.expireCurrentChunk(projectId) + expect(expired).to.be.false + + // Verify the chunk still exists + const cachedChunk = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunk).to.not.be.null + expect(cachedChunk.getStartVersion()).to.equal(10) + }) + + it('should return true and expire an expired chunk using currentTime', async function () { + // Cache a chunk + const snapshot = new Snapshot() + const history = new History(snapshot, []) + const chunk = new Chunk(history, 10) + await redisBackend.setCurrentChunk(projectId, chunk) + + // Calculate a time far enough in the future to ensure expiry + const futureTime = Date.now() + TEMPORARY_CACHE_LIFETIME_MS + 5000 // 5 seconds past expiry + + // Attempt to expire using the future time + const expired = await redisBackend.expireCurrentChunk( + projectId, + futureTime + ) + expect(expired).to.be.true + + // Verify the chunk is gone + const cachedChunk = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunk).to.be.null + + // Verify metadata is also gone + const metadata = await redisBackend.getCurrentChunkMetadata(projectId) + expect(metadata).to.be.null + }) + + it('should return false if no chunk is cached for the project', async function () { + const expired = await redisBackend.expireCurrentChunk( + 'non-existent-project' + ) + expect(expired).to.be.false + }) + + it('should return false if called with a currentTime before the expiry time', async function () { + // Cache a chunk + const snapshot = new Snapshot() + const history = new History(snapshot, []) + const chunk = new Chunk(history, 10) + await redisBackend.setCurrentChunk(projectId, chunk) + + // Use a time *before* the cache would normally expire + const pastTime = Date.now() - 10000 // 10 seconds ago + + // Attempt to expire using the past time + const expired = await redisBackend.expireCurrentChunk(projectId, pastTime) + expect(expired).to.be.false + + // Verify the chunk still exists + const cachedChunk = await redisBackend.getCurrentChunk(projectId) + expect(cachedChunk).to.not.be.null + }) + }) + + describe('with a persist-time timestamp', function () { + const persistTimestamp = Date.now() + 1000 * 60 * 60 // 1 hour in the future + + beforeEach(async function () { + // Ensure a chunk exists before each test in this block + const snapshot = new Snapshot() + const changes = [ + new Change( + [new AddFileOperation('test.tex', File.fromString('Persist Test'))], + new Date(), + [] + ), + ] + const history = new History(snapshot, changes) + const chunk = new Chunk(history, 100) + await redisBackend.setCurrentChunk(projectId, chunk) + }) + + it('should not clear a chunk if persist-time is set', async function () { + // Set persist time + await redisBackend.setPersistTime(projectId, persistTimestamp) + + // Attempt to clear the cache + const cleared = await redisBackend.clearCache(projectId) + expect(cleared).to.be.false // Expect clearCache to return false + + // Verify the chunk still exists + const chunk = await redisBackend.getCurrentChunk(projectId) + expect(chunk).to.not.be.null + expect(chunk.getStartVersion()).to.equal(100) + }) + + it('should not expire a chunk if persist-time is set, even if expire-time has passed', async function () { + // Set persist time + await redisBackend.setPersistTime(projectId, persistTimestamp) + + // Attempt to expire the chunk with a time far in the future + const farFutureTime = Date.now() + 1000 * 60 * 60 * 24 // 24 hours in the future + const expired = await redisBackend.expireCurrentChunk( + projectId, + farFutureTime + ) + expect(expired).to.be.false // Expect expireCurrentChunk to return false + + // Verify the chunk still exists + const chunk = await redisBackend.getCurrentChunk(projectId) + expect(chunk).to.not.be.null + expect(chunk.getStartVersion()).to.equal(100) + }) + + it('getCurrentChunkStatus should return persist-time when set', async function () { + // Set persist time + await redisBackend.setPersistTime(projectId, persistTimestamp) + + const status = await redisBackend.getCurrentChunkStatus(projectId) + expect(status.persistTime).to.equal(persistTimestamp) + expect(status.expireTime).to.be.a('number') // expireTime is set by setCurrentChunk + }) + + it('getCurrentChunkStatus should return null for persist-time when not set', async function () { + const status = await redisBackend.getCurrentChunkStatus(projectId) + expect(status.persistTime).to.be.null + expect(status.expireTime).to.be.a('number') + }) + + it('getCurrentChunkStatus should return nulls after cache is cleared (without persist-time)', async function () { + // Clear cache (persistTime is not set here) + await redisBackend.clearCache(projectId) + + const status = await redisBackend.getCurrentChunkStatus(projectId) + expect(status.persistTime).to.be.null + expect(status.expireTime).to.be.null + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js b/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js index 8f67c17d71..0fb50e49e9 100644 --- a/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js +++ b/services/history-v1/test/acceptance/js/storage/fixtures/chunks.js @@ -15,7 +15,7 @@ exports.chunks = { exports.histories = { chunkOne: { projectId: DocFixtures.initializedProject.id, - chunkId: 1000000, + chunkId: '1000000', json: { snapshot: { files: {} }, changes: [] }, }, } diff --git a/services/history-v1/test/acceptance/js/storage/support/cleanup.js b/services/history-v1/test/acceptance/js/storage/support/cleanup.js index fd0fcbbcb6..55829bef13 100644 --- a/services/history-v1/test/acceptance/js/storage/support/cleanup.js +++ b/services/history-v1/test/acceptance/js/storage/support/cleanup.js @@ -1,6 +1,6 @@ const config = require('config') -const { knex, persistor, mongodb } = require('../../../../../storage') +const { knex, persistor, mongodb, redis } = require('../../../../../storage') const { S3Persistor } = require('@overleaf/object-persistor/src/S3Persistor') const POSTGRES_TABLES = [ @@ -43,6 +43,11 @@ async function cleanupMongo() { } } +async function cleanupRedis() { + await redis.rclientHistory.flushdb() + await redis.rclientLock.flushdb() +} + async function cleanupPersistor() { await Promise.all([ clearBucket(config.get('blobStore.globalBucket')), @@ -82,6 +87,7 @@ async function cleanupEverything() { cleanupMongo(), cleanupPersistor(), cleanupBackup(), + cleanupRedis(), ]) } @@ -90,5 +96,6 @@ module.exports = { mongo: cleanupMongo, persistor: cleanupPersistor, backup: cleanupBackup, + redis: cleanupRedis, everything: cleanupEverything, } diff --git a/services/history-v1/test/acceptance/js/storage/tasks.test.js b/services/history-v1/test/acceptance/js/storage/tasks.test.js index 04f9cd12c3..e43bdac79f 100644 --- a/services/history-v1/test/acceptance/js/storage/tasks.test.js +++ b/services/history-v1/test/acceptance/js/storage/tasks.test.js @@ -76,9 +76,13 @@ describe('tasks', function () { await mongodb.chunks.insertMany(mongoChunks) await Promise.all([ ...postgresChunks.map(chunk => - historyStore.storeRaw(postgresProjectId.toString(), chunk.chunk_id, { - history: 'raw history', - }) + historyStore.storeRaw( + postgresProjectId.toString(), + chunk.chunk_id.toString(), + { + history: 'raw history', + } + ) ), ...mongoChunks.map(chunk => historyStore.storeRaw(mongoProjectId.toString(), chunk._id.toString(), { diff --git a/services/history-v1/test/setup.js b/services/history-v1/test/setup.js index 38c1b283ad..60974173de 100644 --- a/services/history-v1/test/setup.js +++ b/services/history-v1/test/setup.js @@ -2,12 +2,13 @@ const chai = require('chai') const chaiAsPromised = require('chai-as-promised') const config = require('config') const fetch = require('node-fetch') -const { knex, mongodb } = require('../storage') +const { knex, mongodb, redis } = require('../storage') // ensure every ObjectId has the id string as a property for correct comparisons require('mongodb').ObjectId.cacheHexString = true chai.use(chaiAsPromised) +chai.config.truncateThreshold = 0 async function setupPostgresDatabase() { this.timeout(60_000) @@ -20,7 +21,7 @@ async function setupMongoDatabase() { { key: { projectId: 1, startVersion: 1 }, name: 'projectId_1_startVersion_1', - partialFilterExpression: { state: 'active' }, + partialFilterExpression: { state: { $in: ['active', 'closed'] } }, unique: true, }, { @@ -52,6 +53,7 @@ async function createGcsBuckets() { // can exit. async function tearDownConnectionPool() { await knex.destroy() + await redis.disconnect() } module.exports = { diff --git a/services/notifications/buildscript.txt b/services/notifications/buildscript.txt index 10e6804ead..c52e316ffe 100644 --- a/services/notifications/buildscript.txt +++ b/services/notifications/buildscript.txt @@ -6,4 +6,4 @@ notifications --esmock-loader=False --node-version=20.18.2 --public-repo=True ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/services/project-history/app/js/HttpController.js b/services/project-history/app/js/HttpController.js index 766fb4a414..927248726f 100644 --- a/services/project-history/app/js/HttpController.js +++ b/services/project-history/app/js/HttpController.js @@ -238,22 +238,6 @@ export function getFileMetadataSnapshot(req, res, next) { ) } -export function getMostRecentChunk(req, res, next) { - const { project_id: projectId } = req.params - WebApiManager.getHistoryId(projectId, (error, historyId) => { - if (error) return next(OError.tag(error)) - - HistoryStoreManager.getMostRecentChunk( - projectId, - historyId, - (err, data) => { - if (err) return next(OError.tag(err)) - res.json(data) - } - ) - }) -} - export function getLatestSnapshot(req, res, next) { const { project_id: projectId } = req.params WebApiManager.getHistoryId(projectId, (error, historyId) => { @@ -272,25 +256,6 @@ export function getLatestSnapshot(req, res, next) { }) } -export function getChangesSince(req, res, next) { - const { project_id: projectId } = req.params - const { since } = req.query - WebApiManager.getHistoryId(projectId, (error, historyId) => { - if (error) return next(OError.tag(error)) - SnapshotManager.getChangesSince( - projectId, - historyId, - since, - (error, changes) => { - if (error != null) { - return next(error) - } - res.json(changes.map(c => c.toRaw())) - } - ) - }) -} - export function getChangesInChunkSince(req, res, next) { const { project_id: projectId } = req.params const { since } = req.query diff --git a/services/project-history/app/js/Router.js b/services/project-history/app/js/Router.js index d7233a511b..ec9a4f0582 100644 --- a/services/project-history/app/js/Router.js +++ b/services/project-history/app/js/Router.js @@ -22,10 +22,6 @@ export function initialize(app) { app.delete('/project/:project_id', HttpController.deleteProject) app.get('/project/:project_id/snapshot', HttpController.getLatestSnapshot) - app.get( - '/project/:project_id/latest/history', - HttpController.getMostRecentChunk - ) app.get( '/project/:project_id/diff', @@ -61,16 +57,6 @@ export function initialize(app) { HttpController.getUpdates ) - app.get( - '/project/:project_id/changes', - validate({ - query: { - since: Joi.number().integer().min(0), - }, - }), - HttpController.getChangesSince - ) - app.get( '/project/:project_id/changes-in-chunk', validate({ diff --git a/services/project-history/app/js/SnapshotManager.js b/services/project-history/app/js/SnapshotManager.js index e735fd334b..ed316743cf 100644 --- a/services/project-history/app/js/SnapshotManager.js +++ b/services/project-history/app/js/SnapshotManager.js @@ -341,44 +341,6 @@ function getLatestSnapshotFromChunk(data) { } } -async function getChangesSince(projectId, historyId, sinceVersion) { - const allChanges = [] - let nextVersion - while (true) { - let data - if (nextVersion) { - data = await HistoryStoreManager.promises.getChunkAtVersion( - projectId, - historyId, - nextVersion - ) - } else { - data = await HistoryStoreManager.promises.getMostRecentChunk( - projectId, - historyId - ) - } - if (data == null || data.chunk == null) { - throw new OError('undefined chunk') - } - const chunk = Core.Chunk.fromRaw(data.chunk) - if (sinceVersion > chunk.getEndVersion()) { - throw new OError('requested version past the end') - } - const changes = chunk.getChanges() - if (chunk.getStartVersion() > sinceVersion) { - allChanges.unshift(...changes) - nextVersion = chunk.getStartVersion() - } else { - allChanges.unshift( - ...changes.slice(sinceVersion - chunk.getStartVersion()) - ) - break - } - } - return allChanges -} - async function getChangesInChunkSince(projectId, historyId, sinceVersion) { const latestChunk = Core.Chunk.fromRaw( ( @@ -426,7 +388,6 @@ async function _loadFilesLimit(snapshot, kind, blobStore) { // EXPORTS -const getChangesSinceCb = callbackify(getChangesSince) const getChangesInChunkSinceCb = callbackify(getChangesInChunkSince) const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream) const getProjectSnapshotCb = callbackify(getProjectSnapshot) @@ -441,7 +402,6 @@ const getPathsAtVersionCb = callbackify(getPathsAtVersion) export { getLatestSnapshotFromChunk, - getChangesSinceCb as getChangesSince, getChangesInChunkSinceCb as getChangesInChunkSince, getFileSnapshotStreamCb as getFileSnapshotStream, getProjectSnapshotCb as getProjectSnapshot, @@ -454,7 +414,6 @@ export { } export const promises = { - getChangesSince, getChangesInChunkSince, getFileSnapshotStream, getProjectSnapshot, diff --git a/services/project-history/buildscript.txt b/services/project-history/buildscript.txt index 89ed021aa3..be5e751759 100644 --- a/services/project-history/buildscript.txt +++ b/services/project-history/buildscript.txt @@ -6,4 +6,4 @@ project-history --esmock-loader=True --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml index bdf10c9732..6deaad433d 100644 --- a/services/project-history/docker-compose.ci.yml +++ b/services/project-history/docker-compose.ci.yml @@ -21,6 +21,7 @@ services: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis QUEUES_REDIS_HOST: redis + HISTORY_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo POSTGRES_HOST: postgres diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml index 39c7ed9009..deed9c5033 100644 --- a/services/project-history/docker-compose.yml +++ b/services/project-history/docker-compose.yml @@ -30,6 +30,7 @@ services: environment: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis + HISTORY_REDIS_HOST: redis QUEUES_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo diff --git a/services/project-history/test/acceptance/js/GetChangesSince.js b/services/project-history/test/acceptance/js/GetChangesSince.js deleted file mode 100644 index 559432fc73..0000000000 --- a/services/project-history/test/acceptance/js/GetChangesSince.js +++ /dev/null @@ -1,122 +0,0 @@ -import { expect } from 'chai' -import mongodb from 'mongodb-legacy' -import nock from 'nock' -import Core from 'overleaf-editor-core' -import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js' -import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js' -import latestChunk from '../fixtures/chunks/7-8.json' with { type: 'json' } -import previousChunk from '../fixtures/chunks/4-6.json' with { type: 'json' } -import firstChunk from '../fixtures/chunks/0-3.json' with { type: 'json' } -const { ObjectId } = mongodb - -const MockHistoryStore = () => nock('http://127.0.0.1:3100') -const MockWeb = () => nock('http://127.0.0.1:3000') - -const fixture = path => new URL(`../fixtures/${path}`, import.meta.url) - -describe('GetChangesSince', function () { - let projectId, historyId - beforeEach(function (done) { - projectId = new ObjectId().toString() - historyId = new ObjectId().toString() - ProjectHistoryApp.ensureRunning(error => { - if (error) throw error - - MockHistoryStore().post('/api/projects').reply(200, { - projectId: historyId, - }) - - ProjectHistoryClient.initializeProject(historyId, (error, olProject) => { - if (error) throw error - MockWeb() - .get(`/project/${projectId}/details`) - .reply(200, { - name: 'Test Project', - overleaf: { history: { id: olProject.id } }, - }) - - MockHistoryStore() - .get(`/api/projects/${historyId}/latest/history`) - .replyWithFile(200, fixture('chunks/7-8.json')) - MockHistoryStore() - .get(`/api/projects/${historyId}/versions/6/history`) - .replyWithFile(200, fixture('chunks/4-6.json')) - MockHistoryStore() - .get(`/api/projects/${historyId}/versions/3/history`) - .replyWithFile(200, fixture('chunks/0-3.json')) - - done() - }) - }) - }) - - afterEach(function () { - nock.cleanAll() - }) - - function expectChangesSince(version, changes, done) { - ProjectHistoryClient.getChangesSince( - projectId, - version, - {}, - (error, got) => { - if (error) throw error - expect(got.map(c => Core.Change.fromRaw(c))).to.deep.equal( - changes.map(c => Core.Change.fromRaw(c)) - ) - done() - } - ) - } - - it('should return zero changes since the latest version', function (done) { - expectChangesSince(8, [], done) - }) - - it('should return one change when behind one version', function (done) { - expectChangesSince(7, [latestChunk.chunk.history.changes[1]], done) - }) - - it('should return changes when at the chunk boundary', function (done) { - expect(latestChunk.chunk.startVersion).to.equal(6) - expectChangesSince(6, latestChunk.chunk.history.changes, done) - }) - - it('should return changes spanning multiple chunks', function (done) { - expectChangesSince( - 1, - [ - ...firstChunk.chunk.history.changes.slice(1), - ...previousChunk.chunk.history.changes, - ...latestChunk.chunk.history.changes, - ], - done - ) - }) - - it('should return all changes when going back to the beginning', function (done) { - expectChangesSince( - 0, - [ - ...firstChunk.chunk.history.changes, - ...previousChunk.chunk.history.changes, - ...latestChunk.chunk.history.changes, - ], - done - ) - }) - - it('should return an error when past the end version', function (done) { - ProjectHistoryClient.getChangesSince( - projectId, - 9, - { allowErrors: true }, - (error, body, statusCode) => { - if (error) throw error - expect(statusCode).to.equal(500) - expect(body).to.deep.equal({ message: 'an internal error occurred' }) - done() - } - ) - }) -}) diff --git a/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js b/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js index 7a30b27740..92caa4bd0e 100644 --- a/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js +++ b/services/project-history/test/acceptance/js/helpers/ProjectHistoryClient.js @@ -108,25 +108,6 @@ export function getFileTreeDiff(projectId, from, to, callback) { ) } -export function getChangesSince(projectId, since, options, callback) { - request.get( - { - url: `http://127.0.0.1:3054/project/${projectId}/changes`, - qs: { - since, - }, - json: true, - }, - (error, res, body) => { - if (error) return callback(error) - if (!options.allowErrors) { - expect(res.statusCode).to.equal(200) - } - callback(null, body, res.statusCode) - } - ) -} - export function getChangesInChunkSince(projectId, since, options, callback) { request.get( { diff --git a/services/real-time/app.js b/services/real-time/app.js index 85b469bf90..38cb3caec4 100644 --- a/services/real-time/app.js +++ b/services/real-time/app.js @@ -83,6 +83,39 @@ io.configure(function () { io.set('match origin protocol', true) io.set('transports', ['websocket', 'xhr-polling']) + + if (Settings.allowedCorsOrigins) { + // Create a regex for matching origins, allowing wildcard subdomains + const allowedCorsOriginsRegex = new RegExp( + `^${Settings.allowedCorsOrigins.replaceAll('.', '\\.').replace('://*', '://[^.]+')}(?::443)?$` + ) + + io.set('origins', function (origin, req) { + const normalizedOrigin = URL.parse(origin).origin + const originIsValid = allowedCorsOriginsRegex.test(normalizedOrigin) + + if (req.headers.origin) { + if (!originIsValid) { + logger.warn( + { normalizedOrigin, origin, req }, + 'Origin header does not match allowed origins' + ) + } + return originIsValid + } + + if (!originIsValid) { + // There is no Origin header and the Referrer does not satisfy the + // constraints. We're going to pass this anyway for now but log it + logger.warn( + { req, referer: req.headers.referer }, + 'Referrer header does not match allowed origins' + ) + } + + return true + }) + } }) // Serve socket.io.js client file from imported dist folder diff --git a/services/real-time/buildscript.txt b/services/real-time/buildscript.txt index 79980f013f..292fde8b4c 100644 --- a/services/real-time/buildscript.txt +++ b/services/real-time/buildscript.txt @@ -6,4 +6,4 @@ real-time --esmock-loader=False --node-version=20.18.2 --public-repo=False ---script-version=4.5.0 +--script-version=4.7.0 diff --git a/services/real-time/config/settings.defaults.js b/services/real-time/config/settings.defaults.js index 96c116fb2e..57b0a50a42 100644 --- a/services/real-time/config/settings.defaults.js +++ b/services/real-time/config/settings.defaults.js @@ -173,6 +173,7 @@ const settings = { behindProxy: process.env.BEHIND_PROXY === 'true', trustedProxyIps: process.env.TRUSTED_PROXY_IPS, keepAliveTimeoutMs: parseInt(process.env.KEEPALIVE_TIMEOUT_MS ?? '5000', 10), + allowedCorsOrigins: process.env.REAL_TIME_ALLOWED_CORS_ORIGINS, } // console.log settings.redis diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml index ca9813adad..9011627c06 100644 --- a/services/real-time/docker-compose.ci.yml +++ b/services/real-time/docker-compose.ci.yml @@ -21,6 +21,7 @@ services: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis QUEUES_REDIS_HOST: redis + HISTORY_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo POSTGRES_HOST: postgres diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml index 5ff5afe513..d40fada758 100644 --- a/services/real-time/docker-compose.yml +++ b/services/real-time/docker-compose.yml @@ -30,6 +30,7 @@ services: environment: ELASTIC_SEARCH_DSN: es:9200 REDIS_HOST: redis + HISTORY_REDIS_HOST: redis QUEUES_REDIS_HOST: redis ANALYTICS_QUEUES_REDIS_HOST: redis MONGO_HOST: mongo diff --git a/services/real-time/package.json b/services/real-time/package.json index 33c75ecf9f..2d5f87a109 100644 --- a/services/real-time/package.json +++ b/services/real-time/package.json @@ -34,7 +34,7 @@ "lodash": "^4.17.21", "proxy-addr": "^2.0.7", "request": "^2.88.2", - "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-10", + "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-11", "socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5" }, "devDependencies": { diff --git a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js index dd4aeb35c9..a6864075e0 100644 --- a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js +++ b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js @@ -20,6 +20,7 @@ const tk = require('timekeeper') describe('ConnectedUsersManager', function () { beforeEach(function () { + tk.freeze(new Date()) this.settings = { redis: { realtime: { @@ -56,7 +57,6 @@ describe('ConnectedUsersManager', function () { return this.rClient }, } - tk.freeze(new Date()) this.Metrics = { inc: sinon.stub(), histogram: sinon.stub(), diff --git a/services/web/.storybook/preview.tsx b/services/web/.storybook/preview.tsx index ed67abc3c4..e3838a6f97 100644 --- a/services/web/.storybook/preview.tsx +++ b/services/web/.storybook/preview.tsx @@ -9,14 +9,10 @@ import i18n from 'i18next' import { initReactI18next } from 'react-i18next' // @ts-ignore import en from '../../../services/web/locales/en.json' -import { bootstrapVersionArg } from './utils/with-bootstrap-switcher' -function resetMeta(bootstrapVersion?: 3 | 5) { +function resetMeta() { window.metaAttributesCache = new Map() window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' }) - if (bootstrapVersion) { - window.metaAttributesCache.set('ol-bootstrapVersion', bootstrapVersion) - } window.metaAttributesCache.set('ol-ExposedSettings', { adminEmail: 'placeholder@example.com', appName: 'Overleaf', @@ -126,8 +122,6 @@ const preview: Preview = { // render stories in iframes, to isolate modals inlineStories: false, }, - // Default to Bootstrap 5 styles - bootstrap5: true, }, globalTypes: { theme: { @@ -144,17 +138,9 @@ const preview: Preview = { }, }, loaders: [ - async ({ globals }) => { - const { theme } = globals - + async () => { return { - // NOTE: this uses `${theme}style.less` rather than `${theme}.less` - // so that webpack only bundles files ending with "style.less" - bootstrap3Style: await import( - `!!to-string-loader!css-loader!less-loader!../../../services/web/frontend/stylesheets/${theme}style.less` - ), - // Themes are applied differently in Bootstrap 5 code - bootstrap5Style: await import( + mainStyle: await import( // @ts-ignore `!!to-string-loader!css-loader!resolve-url-loader!sass-loader!../../../services/web/frontend/stylesheets/bootstrap-5/main-style.scss` ), @@ -163,15 +149,9 @@ const preview: Preview = { ], decorators: [ (Story, context) => { - const { bootstrap3Style, bootstrap5Style } = context.loaded - const bootstrapVersion = Number( - context.args[bootstrapVersionArg] || - (context.parameters.bootstrap5 === false ? 3 : 5) - ) as 3 | 5 - const activeStyle = - bootstrapVersion === 3 ? bootstrap3Style : bootstrap5Style + const { mainStyle } = context.loaded - resetMeta(bootstrapVersion) + resetMeta() return (
- {activeStyle && } - + {mainStyle && } +
) }, diff --git a/services/web/.storybook/utils/with-bootstrap-switcher.tsx b/services/web/.storybook/utils/with-bootstrap-switcher.tsx deleted file mode 100644 index 640338dbb0..0000000000 --- a/services/web/.storybook/utils/with-bootstrap-switcher.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Meta } from '@storybook/react' - -export const bootstrapVersionArg = 'bootstrapVersion' - -export const bsVersionDecorator: Meta = { - argTypes: { - [bootstrapVersionArg]: { - name: 'Bootstrap Version', - description: 'Bootstrap version for components', - control: { type: 'inline-radio' }, - options: ['3', '5'], - table: { - defaultValue: { summary: '5' }, - }, - }, - }, - args: { - [bootstrapVersionArg]: '5', - }, -} diff --git a/services/web/app/src/Features/Authentication/AuthenticationManager.js b/services/web/app/src/Features/Authentication/AuthenticationManager.js index 33827f1673..6ac510986c 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationManager.js +++ b/services/web/app/src/Features/Authentication/AuthenticationManager.js @@ -409,10 +409,6 @@ const AuthenticationManager = { if (!_exceedsMaximumLengthRatio(password, MAX_SIMILARITY, emailPart)) { const similarity = DiffHelper.stringSimilarity(password, emailPart) if (similarity > MAX_SIMILARITY) { - logger.warn( - { email, emailPart, similarity, maxSimilarity: MAX_SIMILARITY }, - 'Password too similar to email' - ) return new Error('password is too similar to email') } } diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js index 05137a97f8..96b4cd6e37 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js @@ -122,9 +122,26 @@ async function removeUserFromAllProjects(userId) { .concat(readOnly) .concat(tokenReadAndWrite) .concat(tokenReadOnly) + logger.info( + { + userId, + readAndWriteCount: readAndWrite.length, + readOnlyCount: readOnly.length, + tokenReadAndWriteCount: tokenReadAndWrite.length, + tokenReadOnlyCount: tokenReadOnly.length, + }, + 'removing user from projects' + ) for (const project of allProjects) { await removeUserFromProject(project._id, userId) } + logger.info( + { + userId, + allProjectsCount: allProjects.length, + }, + 'removed user from all projects' + ) } async function addUserIdToProject( diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs index c6ffba1ea5..4c2d911709 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs @@ -16,6 +16,7 @@ import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js' import Errors from '../Errors/Errors.js' import AuthenticationController from '../Authentication/AuthenticationController.js' import PrivilegeLevels from '../Authorization/PrivilegeLevels.js' +import SplitTestHandler from '../SplitTests/SplitTestHandler.js' // This rate limiter allows a different number of requests depending on the // number of callaborators a user is allowed. This is implemented by providing @@ -244,6 +245,10 @@ async function generateNewInvite(req, res) { async function viewInvite(req, res) { const projectId = req.params.Project_id const { token } = req.params + + // Read split test assignment so that it's available for Pug to read + await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5') + const _renderInvalidPage = function () { res.status(404) logger.debug({ projectId }, 'invite not valid, rendering not-valid page') diff --git a/services/web/app/src/Features/Compile/ClsiCacheController.js b/services/web/app/src/Features/Compile/ClsiCacheController.js new file mode 100644 index 0000000000..9795fd3ef2 --- /dev/null +++ b/services/web/app/src/Features/Compile/ClsiCacheController.js @@ -0,0 +1,193 @@ +const { NotFoundError } = require('../Errors/Errors') +const { + fetchStreamWithResponse, + RequestFailedError, + fetchJson, +} = require('@overleaf/fetch-utils') +const Path = require('path') +const { pipeline } = require('stream/promises') +const logger = require('@overleaf/logger') +const ClsiCacheManager = require('./ClsiCacheManager') +const CompileController = require('./CompileController') +const { expressify } = require('@overleaf/promise-utils') +const ClsiCacheHandler = require('./ClsiCacheHandler') +const ProjectGetter = require('../Project/ProjectGetter') + +/** + * Download a file from a specific build on the clsi-cache. + * + * @param req + * @param res + * @return {Promise<*>} + */ +async function downloadFromCache(req, res) { + const { Project_id: projectId, buildId, filename } = req.params + const userId = CompileController._getUserIdForCompile(req) + const signal = AbortSignal.timeout(60 * 1000) + let location, projectName + try { + ;[{ location }, { name: projectName }] = await Promise.all([ + ClsiCacheHandler.getOutputFile( + projectId, + userId, + buildId, + filename, + signal + ), + ProjectGetter.promises.getProject(projectId, { name: 1 }), + ]) + } catch (err) { + if (err instanceof NotFoundError) { + // res.sendStatus() sends a description of the status as body. + // Using res.status().end() avoids sending that fake body. + return res.status(404).end() + } else { + throw err + } + } + + const { stream, response } = await fetchStreamWithResponse(location, { + signal, + }) + if (req.destroyed) { + // The client has disconnected already, avoid trying to write into the broken connection. + return + } + + for (const key of ['Content-Length', 'Content-Type']) { + if (response.headers.has(key)) res.setHeader(key, response.headers.get(key)) + } + const ext = Path.extname(filename) + res.attachment( + ext === '.pdf' + ? `${CompileController._getSafeProjectName({ name: projectName })}.pdf` + : filename + ) + try { + res.writeHead(response.status) + await pipeline(stream, res) + } catch (err) { + const reqAborted = Boolean(req.destroyed) + const streamingStarted = Boolean(res.headersSent) + if (!streamingStarted) { + if (err instanceof RequestFailedError) { + res.sendStatus(err.response.status) + } else { + res.sendStatus(500) + } + } + if ( + streamingStarted && + reqAborted && + err.code === 'ERR_STREAM_PREMATURE_CLOSE' + ) { + // Ignore noisy spurious error + return + } + logger.warn( + { + err, + projectId, + location, + filename, + reqAborted, + streamingStarted, + }, + 'CLSI-cache proxy error' + ) + } +} + +/** + * Prepare a compile response from the clsi-cache. + * + * @param req + * @param res + * @return {Promise} + */ +async function getLatestBuildFromCache(req, res) { + const { Project_id: projectId } = req.params + const userId = CompileController._getUserIdForCompile(req) + try { + const { + internal: { location: metaLocation, zone }, + external: { isUpToDate, allFiles }, + } = await ClsiCacheManager.getLatestBuildFromCache( + projectId, + userId, + 'output.overleaf.json' + ) + + if (!isUpToDate) return res.sendStatus(410) + + const meta = await fetchJson(metaLocation, { + signal: AbortSignal.timeout(5 * 1000), + }) + + const [, editorId, buildId] = metaLocation.match( + /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\// + ) + + let baseURL = `/project/${projectId}` + if (userId) { + baseURL += `/user/${userId}` + } + + const { ranges, contentId, clsiServerId, compileGroup, size, options } = + meta + + const outputFiles = allFiles + .filter( + path => path !== 'output.overleaf.json' && path !== 'output.tar.gz' + ) + .map(path => { + const f = { + url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`, + downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`, + build: buildId, + path, + type: path.split('.').pop(), + } + if (path === 'output.pdf') { + Object.assign(f, { + size, + editorId, + }) + if (clsiServerId !== 'cache') { + // Enable PDF caching and attempt to download from VM first. + // (clsi VMs do not have the editorId in the path on disk, omit it). + Object.assign(f, { + url: `${baseURL}/build/${buildId}/output/output.pdf`, + ranges, + contentId, + }) + } + } + return f + }) + let { pdfCachingMinChunkSize, pdfDownloadDomain } = + await CompileController._getSplitTestOptions(req, res) + pdfDownloadDomain += `/zone/${zone}` + res.json({ + fromCache: true, + status: 'success', + outputFiles, + compileGroup, + clsiServerId, + pdfDownloadDomain, + pdfCachingMinChunkSize, + options, + }) + } catch (err) { + if (err instanceof NotFoundError) { + res.sendStatus(404) + } else { + throw err + } + } +} + +module.exports = { + downloadFromCache: expressify(downloadFromCache), + getLatestBuildFromCache: expressify(getLatestBuildFromCache), +} diff --git a/services/web/app/src/Features/Compile/ClsiCacheHandler.js b/services/web/app/src/Features/Compile/ClsiCacheHandler.js new file mode 100644 index 0000000000..54ebd9e259 --- /dev/null +++ b/services/web/app/src/Features/Compile/ClsiCacheHandler.js @@ -0,0 +1,218 @@ +const _ = require('lodash') +const { + fetchNothing, + fetchRedirectWithResponse, + RequestFailedError, +} = require('@overleaf/fetch-utils') +const logger = require('@overleaf/logger') +const Settings = require('@overleaf/settings') +const OError = require('@overleaf/o-error') +const { NotFoundError, InvalidNameError } = require('../Errors/Errors') + +function validateFilename(filename) { + if ( + !( + [ + 'output.blg', + 'output.log', + 'output.pdf', + 'output.synctex.gz', + 'output.overleaf.json', + 'output.tar.gz', + ].includes(filename) || filename.endsWith('.blg') + ) + ) { + throw new InvalidNameError('bad filename') + } +} + +/** + * Clear the cache on all clsi-cache instances. + * + * @param projectId + * @param userId + * @return {Promise} + */ +async function clearCache(projectId, userId) { + let path = `/project/${projectId}` + if (userId) { + path += `/user/${userId}` + } + path += '/output' + + await Promise.all( + Settings.apis.clsiCache.instances.map(async ({ url, zone }) => { + const u = new URL(url) + u.pathname = path + try { + await fetchNothing(u, { + method: 'DELETE', + signal: AbortSignal.timeout(15_000), + }) + } catch (err) { + throw OError.tag(err, 'clear clsi-cache', { url, zone }) + } + }) + ) +} + +/** + * Get an output file from a specific build. + * + * @param projectId + * @param userId + * @param buildId + * @param filename + * @param signal + * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>} + */ +async function getOutputFile( + projectId, + userId, + buildId, + filename, + signal = AbortSignal.timeout(15_000) +) { + validateFilename(filename) + if (!/^[a-f0-9-]+$/.test(buildId)) { + throw new InvalidNameError('bad buildId') + } + + let path = `/project/${projectId}` + if (userId) { + path += `/user/${userId}` + } + path += `/build/${buildId}/search/output/${filename}` + return getRedirectWithFallback(projectId, userId, path, signal) +} + +/** + * Get an output file from the most recent build. + * + * @param projectId + * @param userId + * @param filename + * @param signal + * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>} + */ +async function getLatestOutputFile( + projectId, + userId, + filename, + signal = AbortSignal.timeout(15_000) +) { + validateFilename(filename) + + let path = `/project/${projectId}` + if (userId) { + path += `/user/${userId}` + } + path += `/latest/output/${filename}` + return getRedirectWithFallback(projectId, userId, path, signal) +} + +/** + * Request the given path from any of the clsi-cache instances. + * + * Some of them might be down temporarily. Try the next one until we receive a redirect or 404. + * + * This function is similar to the Coordinator in the clsi-cache, notable differences: + * - all the logic for sorting builds is in clsi-cache (re-used by clsi and web) + * - fan-out (1 client performs lookup on many clsi-cache instances) is "central" in clsi-cache, resulting in better connection re-use + * - we only cross the k8s cluster boundary via an internal GCLB once ($$$) + * + * @param projectId + * @param userId + * @param path + * @param signal + * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>} + */ +async function getRedirectWithFallback( + projectId, + userId, + path, + signal = AbortSignal.timeout(15_000) +) { + // Avoid hitting the same instance first all the time. + const instances = _.shuffle(Settings.apis.clsiCache.instances) + for (const { url, zone } of instances) { + const u = new URL(url) + u.pathname = path + try { + const { + location, + response: { headers }, + } = await fetchRedirectWithResponse(u, { + signal, + }) + // Success, return the cache entry. + return { + location, + zone: headers.get('X-Zone'), + lastModified: new Date(headers.get('X-Last-Modified')), + size: parseInt(headers.get('X-Content-Length'), 10), + allFiles: JSON.parse(headers.get('X-All-Files')), + } + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + break // No clsi-cache instance has cached something for this project/user. + } + logger.warn( + { err, projectId, userId, url, zone }, + 'getLatestOutputFile from clsi-cache failed' + ) + // This clsi-cache instance is down, try the next backend. + } + } + throw new NotFoundError('nothing cached yet') +} + +/** + * Populate the clsi-cache for the given project/user with the provided source + * + * This is either another project, or a template (id+version). + * + * @param projectId + * @param userId + * @param sourceProjectId + * @param templateId + * @param templateVersionId + * @param lastUpdated + * @param zone + * @param signal + * @return {Promise} + */ +async function prepareCacheSource( + projectId, + userId, + { sourceProjectId, templateId, templateVersionId, lastUpdated, zone, signal } +) { + const url = new URL( + `/project/${projectId}/user/${userId}/import-from`, + Settings.apis.clsiCache.instances.find(i => i.zone === zone).url + ) + try { + await fetchNothing(url, { + method: 'POST', + json: { + sourceProjectId, + lastUpdated, + templateId, + templateVersionId, + }, + signal, + }) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + throw new NotFoundError() + } + throw err + } +} + +module.exports = { + clearCache, + getOutputFile, + getLatestOutputFile, + prepareCacheSource, +} diff --git a/services/web/app/src/Features/Compile/ClsiCacheManager.js b/services/web/app/src/Features/Compile/ClsiCacheManager.js new file mode 100644 index 0000000000..3fe4b987c5 --- /dev/null +++ b/services/web/app/src/Features/Compile/ClsiCacheManager.js @@ -0,0 +1,111 @@ +const { NotFoundError } = require('../Errors/Errors') +const ClsiCacheHandler = require('./ClsiCacheHandler') +const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') +const ProjectGetter = require('../Project/ProjectGetter') +const SplitTestHandler = require('../SplitTests/SplitTestHandler') +const UserGetter = require('../User/UserGetter') + +/** + * Get the most recent build and metadata + * + * Internal: internal metadata; External: fine to send to user as-is. + * + * @param projectId + * @param userId + * @param filename + * @param signal + * @return {Promise<{internal: {zone: string, location: string}, external: {isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>} + */ +async function getLatestBuildFromCache(projectId, userId, filename, signal) { + const [ + { location, lastModified: lastCompiled, zone, size, allFiles }, + lastUpdatedInRedis, + { lastUpdated: lastUpdatedInMongo }, + ] = await Promise.all([ + ClsiCacheHandler.getLatestOutputFile(projectId, userId, filename, signal), + DocumentUpdaterHandler.promises.getProjectLastUpdatedAt(projectId), + ProjectGetter.promises.getProject(projectId, { lastUpdated: 1 }), + ]) + + const lastUpdated = + lastUpdatedInRedis > lastUpdatedInMongo + ? lastUpdatedInRedis + : lastUpdatedInMongo + const isUpToDate = lastCompiled >= lastUpdated + + return { + internal: { + location, + zone, + }, + external: { + isUpToDate, + lastUpdated, + size, + allFiles, + }, + } +} + +/** + * Collect metadata and prepare the clsi-cache for the given project. + * + * @param projectId + * @param userId + * @param sourceProjectId + * @param templateId + * @param templateVersionId + * @return {Promise} + */ +async function prepareClsiCache( + projectId, + userId, + { sourceProjectId, templateId, templateVersionId } +) { + const { variant } = await SplitTestHandler.promises.getAssignmentForUser( + userId, + 'copy-clsi-cache' + ) + if (variant !== 'enabled') return + + const features = await UserGetter.promises.getUserFeatures(userId) + if (features.compileGroup !== 'priority') return + + const signal = AbortSignal.timeout(5_000) + let lastUpdated + let zone = 'b' // populate template data on zone b + if (sourceProjectId) { + try { + ;({ + internal: { zone }, + external: { lastUpdated }, + } = await getLatestBuildFromCache( + sourceProjectId, + userId, + 'output.tar.gz', + signal + )) + } catch (err) { + if (err instanceof NotFoundError) return // nothing cached yet + throw err + } + } + try { + await ClsiCacheHandler.prepareCacheSource(projectId, userId, { + sourceProjectId, + templateId, + templateVersionId, + zone, + lastUpdated, + signal, + }) + } catch (err) { + if (err instanceof NotFoundError) return // nothing cached yet/expired. + throw err + } +} + +module.exports = { + getLatestBuildFromCache, + prepareClsiCache, +} diff --git a/services/web/app/src/Features/Compile/ClsiManager.js b/services/web/app/src/Features/Compile/ClsiManager.js index 68c8a9c0de..2e32aa9622 100644 --- a/services/web/app/src/Features/Compile/ClsiManager.js +++ b/services/web/app/src/Features/Compile/ClsiManager.js @@ -25,6 +25,7 @@ const ClsiFormatChecker = require('./ClsiFormatChecker') const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') const Metrics = require('@overleaf/metrics') const Errors = require('../Errors/Errors') +const ClsiCacheHandler = require('./ClsiCacheHandler') const { getBlobLocation } = require('../History/HistoryManager') const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex'] @@ -148,6 +149,13 @@ async function deleteAuxFiles(projectId, userId, options, clsiserverid) { clsiserverid ) } finally { + // always clear the clsi-cache + try { + await ClsiCacheHandler.clearCache(projectId, userId) + } catch (err) { + logger.warn({ err, projectId, userId }, 'purge clsi-cache failed') + } + // always clear the project state from the docupdater, even if there // was a problem with the request to the clsi try { @@ -766,6 +774,7 @@ function _finaliseRequest(projectId, options, project, docs, files) { compile: { options: { buildId: options.buildId, + editorId: options.editorId, compiler: project.compiler, timeout: options.timeout, imageName: project.imageName, @@ -775,6 +784,13 @@ function _finaliseRequest(projectId, options, project, docs, files) { syncType: options.syncType, syncState: options.syncState, compileGroup: options.compileGroup, + // Overleaf alpha/staff users get compileGroup=alpha (via getProjectCompileLimits in CompileManager), enroll them into the premium rollout of clsi-cache. + compileFromClsiCache: + ['alpha', 'priority'].includes(options.compileGroup) && + options.compileFromClsiCache, + populateClsiCache: + ['alpha', 'priority'].includes(options.compileGroup) && + options.populateClsiCache, enablePdfCaching: (Settings.enablePdfCaching && options.enablePdfCaching) || false, pdfCachingMinChunkSize: options.pdfCachingMinChunkSize, diff --git a/services/web/app/src/Features/Compile/CompileController.js b/services/web/app/src/Features/Compile/CompileController.js index 327c7cc9a3..5d2bbcda3e 100644 --- a/services/web/app/src/Features/Compile/CompileController.js +++ b/services/web/app/src/Features/Compile/CompileController.js @@ -57,7 +57,7 @@ async function getPdfCachingMinChunkSize(req, res) { return parseInt(variant, 10) } -const getSplitTestOptions = callbackify(async function (req, res) { +async function _getSplitTestOptions(req, res) { // Use the query flags from the editor request for overriding the split test. let query = {} try { @@ -66,11 +66,31 @@ const getSplitTestOptions = callbackify(async function (req, res) { } catch (e) {} const editorReq = { ...req, query } + // Lookup the clsi-cache flag in the backend. + // We may need to turn off the feature on a short notice, without requiring + // all users to reload their editor page to disable the feature. + const { variant: compileFromClsiCacheVariant } = + await SplitTestHandler.promises.getAssignment( + editorReq, + res, + 'compile-from-clsi-cache' + ) + const compileFromClsiCache = compileFromClsiCacheVariant === 'enabled' + const { variant: populateClsiCacheVariant } = + await SplitTestHandler.promises.getAssignment( + editorReq, + res, + 'populate-clsi-cache' + ) + const populateClsiCache = populateClsiCacheVariant === 'enabled' + const pdfDownloadDomain = Settings.pdfDownloadDomain if (!req.query.enable_pdf_caching) { // The frontend does not want to do pdf caching. return { + compileFromClsiCache, + populateClsiCache, pdfDownloadDomain, enablePdfCaching: false, } @@ -88,17 +108,22 @@ const getSplitTestOptions = callbackify(async function (req, res) { if (!enablePdfCaching) { // Skip the lookup of the chunk size when caching is not enabled. return { + compileFromClsiCache, + populateClsiCache, pdfDownloadDomain, enablePdfCaching: false, } } const pdfCachingMinChunkSize = await getPdfCachingMinChunkSize(editorReq, res) return { + compileFromClsiCache, + populateClsiCache, pdfDownloadDomain, enablePdfCaching, pdfCachingMinChunkSize, } -}) +} +const getSplitTestOptionsCb = callbackify(_getSplitTestOptions) module.exports = CompileController = { compile(req, res, next) { @@ -112,6 +137,7 @@ module.exports = CompileController = { isAutoCompile, fileLineErrors, stopOnFirstError, + editorId: req.body.editorId, } if (req.body.rootDoc_id) { @@ -136,10 +162,17 @@ module.exports = CompileController = { options.incrementalCompilesEnabled = true } - getSplitTestOptions(req, res, (err, splitTestOptions) => { + getSplitTestOptionsCb(req, res, (err, splitTestOptions) => { if (err) return next(err) - let { enablePdfCaching, pdfCachingMinChunkSize, pdfDownloadDomain } = - splitTestOptions + let { + compileFromClsiCache, + populateClsiCache, + enablePdfCaching, + pdfCachingMinChunkSize, + pdfDownloadDomain, + } = splitTestOptions + options.compileFromClsiCache = compileFromClsiCache + options.populateClsiCache = populateClsiCache options.enablePdfCaching = enablePdfCaching if (enablePdfCaching) { options.pdfCachingMinChunkSize = pdfCachingMinChunkSize @@ -170,14 +203,7 @@ module.exports = CompileController = { pdfDownloadDomain += outputUrlPrefix } - if ( - limits && - SplitTestHandler.getPercentile( - AnalyticsManager.getIdsFromSession(req.session).analyticsId, - 'compile-result-backend', - 'release' - ) === 1 - ) { + if (limits) { // For a compile request to be sent to clsi we need limits. // If we get here without having the limits object populated, it is // a reasonable assumption to make that nothing was compiled. @@ -193,6 +219,8 @@ module.exports = CompileController = { timeout: limits.timeout === 60 ? 'short' : 'long', server: clsiServerId?.includes('-c2d-') ? 'faster' : 'normal', isAutoCompile, + isInitialCompile: stats?.isInitialCompile === 1, + restoredClsiCache: stats?.restoredClsiCache === 1, stopOnFirstError, } ) @@ -271,25 +299,20 @@ module.exports = CompileController = { ) }, - _compileAsUser(req, callback) { - // callback with userId if per-user, undefined otherwise - if (!Settings.disablePerUserCompiles) { - const userId = SessionManager.getLoggedInUserId(req.session) - callback(null, userId) - } else { - callback() - } - }, // do a per-project compile, not per-user + _getSplitTestOptions, - _downloadAsUser(req, callback) { - // callback with userId if per-user, undefined otherwise + _getUserIdForCompile(req) { if (!Settings.disablePerUserCompiles) { - const userId = SessionManager.getLoggedInUserId(req.session) - callback(null, userId) - } else { - callback() + return SessionManager.getLoggedInUserId(req.session) } - }, // do a per-project compile, not per-user + return null + }, + _compileAsUser(req, callback) { + callback(null, CompileController._getUserIdForCompile(req)) + }, + _downloadAsUser(req, callback) { + callback(null, CompileController._getUserIdForCompile(req)) + }, downloadPdf(req, res, next) { Metrics.inc('pdf-downloads') @@ -497,7 +520,7 @@ module.exports = CompileController = { proxySyncPdf(req, res, next) { const projectId = req.params.Project_id - const { page, h, v } = req.query + const { page, h, v, editorId, buildId } = req.query if (!page?.match(/^\d+$/)) { return next(new Error('invalid page parameter')) } @@ -515,23 +538,29 @@ module.exports = CompileController = { getImageNameForProject(projectId, (error, imageName) => { if (error) return next(error) - const url = CompileController._getUrl(projectId, userId, 'sync/pdf') - CompileController.proxyToClsi( - projectId, - 'sync-to-pdf', - url, - { page, h, v, imageName }, - req, - res, - next - ) + getSplitTestOptionsCb(req, res, (error, splitTestOptions) => { + if (error) return next(error) + const { compileFromClsiCache } = splitTestOptions + + const url = CompileController._getUrl(projectId, userId, 'sync/pdf') + + CompileController.proxyToClsi( + projectId, + 'sync-to-pdf', + url, + { page, h, v, imageName, editorId, buildId, compileFromClsiCache }, + req, + res, + next + ) + }) }) }) }, proxySyncCode(req, res, next) { const projectId = req.params.Project_id - const { file, line, column } = req.query + const { file, line, column, editorId, buildId } = req.query if (file == null) { return next(new Error('missing file parameter')) } @@ -557,16 +586,29 @@ module.exports = CompileController = { getImageNameForProject(projectId, (error, imageName) => { if (error) return next(error) - const url = CompileController._getUrl(projectId, userId, 'sync/code') - CompileController.proxyToClsi( - projectId, - 'sync-to-code', - url, - { file, line, column, imageName }, - req, - res, - next - ) + getSplitTestOptionsCb(req, res, (error, splitTestOptions) => { + if (error) return next(error) + const { compileFromClsiCache } = splitTestOptions + + const url = CompileController._getUrl(projectId, userId, 'sync/code') + CompileController.proxyToClsi( + projectId, + 'sync-to-code', + url, + { + file, + line, + column, + imageName, + editorId, + buildId, + compileFromClsiCache, + }, + req, + res, + next + ) + }) }) }) }, diff --git a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js index 493b812dab..2ea06601c7 100644 --- a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js +++ b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js @@ -64,6 +64,11 @@ function flushProjectToMongoAndDelete(projectId, callback) { ) } +/** + * @param {string} projectId + * @param {string} docId + * @param {Callback} callback + */ function flushDocToMongo(projectId, docId, callback) { _makeRequest( { diff --git a/services/web/app/src/Features/Editor/EditorController.js b/services/web/app/src/Features/Editor/EditorController.js index ea0153fe37..4d3a5e9eb0 100644 --- a/services/web/app/src/Features/Editor/EditorController.js +++ b/services/web/app/src/Features/Editor/EditorController.js @@ -307,6 +307,7 @@ const EditorController = { projectId, folderId, folderName, + userId, (err, folder, folderId) => { if (err) { OError.tag(err, 'could not add folder', { @@ -333,11 +334,12 @@ const EditorController = { ) }, - mkdirp(projectId, path, callback) { + mkdirp(projectId, path, userId, callback) { logger.debug({ projectId, path }, "making directories if they don't exist") ProjectEntityUpdateHandler.mkdirp( projectId, path, + userId, (err, newFolders, lastFolder) => { if (err) { OError.tag(err, 'could not mkdirp', { diff --git a/services/web/app/src/Features/History/HistoryController.js b/services/web/app/src/Features/History/HistoryController.js index f6ca483dce..a0f0183f44 100644 --- a/services/web/app/src/Features/History/HistoryController.js +++ b/services/web/app/src/Features/History/HistoryController.js @@ -1,8 +1,17 @@ -let HistoryController +// @ts-check + +const { setTimeout } = require('timers/promises') +const { pipeline } = require('stream/promises') const OError = require('@overleaf/o-error') -const async = require('async') const logger = require('@overleaf/logger') -const request = require('request') +const { expressify } = require('@overleaf/promise-utils') +const { + fetchStream, + fetchStreamWithResponse, + fetchJson, + fetchNothing, + RequestFailedError, +} = require('@overleaf/fetch-utils') const settings = require('@overleaf/settings') const SessionManager = require('../Authentication/SessionManager') const UserGetter = require('../User/UserGetter') @@ -12,11 +21,8 @@ const HistoryManager = require('./HistoryManager') const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler') const ProjectEntityUpdateHandler = require('../Project/ProjectEntityUpdateHandler') const RestoreManager = require('./RestoreManager') -const { pipeline } = require('stream') -const Stream = require('stream') const { prepareZipAttachment } = require('../../infrastructure/Response') const Features = require('../../infrastructure/Features') -const { expressify } = require('@overleaf/promise-utils') // Number of seconds after which the browser should send a request to revalidate // blobs @@ -26,6 +32,8 @@ const REVALIDATE_BLOB_AFTER_SECONDS = 86400 // 1 day // revalidating const STALE_WHILE_REVALIDATE_SECONDS = 365 * 86400 // 1 year +const MAX_HISTORY_ZIP_ATTEMPTS = 40 + async function getBlob(req, res) { await requestBlob('GET', req, res) } @@ -44,9 +52,9 @@ async function requestBlob(method, req, res) { } const range = req.get('Range') - let url, stream, source, contentLength + let stream, source, contentLength try { - ;({ url, stream, source, contentLength } = + ;({ stream, source, contentLength } = await HistoryManager.promises.requestBlobWithFallback( projectId, hash, @@ -65,14 +73,13 @@ async function requestBlob(method, req, res) { setBlobCacheHeaders(res, hash) try { - await Stream.promises.pipeline(stream, res) + await pipeline(stream, res) } catch (err) { // If the downstream request is cancelled, we get an // ERR_STREAM_PREMATURE_CLOSE, ignore these "errors". - if (err?.code === 'ERR_STREAM_PREMATURE_CLOSE') return - - logger.warn({ err, url, method, range }, 'streaming blob error') - throw err + if (!isPrematureClose(err)) { + throw err + } } } @@ -88,492 +95,408 @@ function setBlobCacheHeaders(res, etag) { res.set('ETag', etag) } -module.exports = HistoryController = { - getBlob: expressify(getBlob), - headBlob: expressify(headBlob), +async function proxyToHistoryApi(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + const url = settings.apis.project_history.url + req.url - proxyToHistoryApi(req, res, next) { - const userId = SessionManager.getLoggedInUserId(req.session) - const url = settings.apis.project_history.url + req.url + const { stream, response } = await fetchStreamWithResponse(url, { + method: req.method, + headers: { 'X-User-Id': userId }, + }) - const getReq = request({ - url, - method: req.method, - headers: { - 'X-User-Id': userId, - }, - }) - pipeline(getReq, res, function (err) { + const contentType = response.headers.get('Content-Type') + const contentLength = response.headers.get('Content-Length') + if (contentType != null) { + res.set('Content-Type', contentType) + } + if (contentLength != null) { + res.set('Content-Length', contentLength) + } + + try { + await pipeline(stream, res) + } catch (err) { + // If the downstream request is cancelled, we get an + // ERR_STREAM_PREMATURE_CLOSE. + if (!isPrematureClose(err)) { + throw err + } + } +} + +async function proxyToHistoryApiAndInjectUserDetails(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + const url = settings.apis.project_history.url + req.url + const body = await fetchJson(url, { + method: req.method, + headers: { 'X-User-Id': userId }, + }) + const data = await HistoryManager.promises.injectUserDetails(body) + res.json(data) +} + +async function resyncProjectHistory(req, res, next) { + // increase timeout to 6 minutes + res.setTimeout(6 * 60 * 1000) + const projectId = req.params.Project_id + const opts = {} + const historyRangesMigration = req.body.historyRangesMigration + if (historyRangesMigration) { + opts.historyRangesMigration = historyRangesMigration + } + if (req.body.resyncProjectStructureOnly) { + opts.resyncProjectStructureOnly = req.body.resyncProjectStructureOnly + } + + try { + await ProjectEntityUpdateHandler.promises.resyncProjectHistory( + projectId, + opts + ) + } catch (err) { + if (err instanceof Errors.ProjectHistoryDisabledError) { + return res.sendStatus(404) + } else { + throw err + } + } + + res.sendStatus(204) +} + +async function restoreFileFromV2(req, res, next) { + const { project_id: projectId } = req.params + const { version, pathname } = req.body + const userId = SessionManager.getLoggedInUserId(req.session) + + const entity = await RestoreManager.promises.restoreFileFromV2( + userId, + projectId, + version, + pathname + ) + + res.json({ + type: entity.type, + id: entity._id, + }) +} + +async function revertFile(req, res, next) { + const { project_id: projectId } = req.params + const { version, pathname } = req.body + const userId = SessionManager.getLoggedInUserId(req.session) + + const entity = await RestoreManager.promises.revertFile( + userId, + projectId, + version, + pathname, + {} + ) + + res.json({ + type: entity.type, + id: entity._id, + }) +} + +async function revertProject(req, res, next) { + const { project_id: projectId } = req.params + const { version } = req.body + const userId = SessionManager.getLoggedInUserId(req.session) + + await RestoreManager.promises.revertProject(userId, projectId, version) + + res.sendStatus(200) +} + +async function getLabels(req, res, next) { + const projectId = req.params.Project_id + + let labels = await fetchJson( + `${settings.apis.project_history.url}/project/${projectId}/labels` + ) + labels = await _enrichLabels(labels) + + res.json(labels) +} + +async function createLabel(req, res, next) { + const projectId = req.params.Project_id + const { comment, version } = req.body + const userId = SessionManager.getLoggedInUserId(req.session) + + let label = await fetchJson( + `${settings.apis.project_history.url}/project/${projectId}/labels`, + { + method: 'POST', + json: { comment, version, user_id: userId }, + } + ) + label = await _enrichLabel(label) + + res.json(label) +} + +async function _enrichLabel(label) { + const newLabel = Object.assign({}, label) + if (!label.user_id) { + newLabel.user_display_name = _displayNameForUser(null) + return newLabel + } + + const user = await UserGetter.promises.getUser(label.user_id, { + first_name: 1, + last_name: 1, + email: 1, + }) + newLabel.user_display_name = _displayNameForUser(user) + return newLabel +} + +async function _enrichLabels(labels) { + if (!labels || !labels.length) { + return [] + } + const uniqueUsers = new Set(labels.map(label => label.user_id)) + + // For backwards compatibility, and for anonymously created labels in SP + // expect missing user_id fields + uniqueUsers.delete(undefined) + + if (!uniqueUsers.size) { + return labels + } + + const rawUsers = await UserGetter.promises.getUsers(Array.from(uniqueUsers), { + first_name: 1, + last_name: 1, + email: 1, + }) + const users = new Map(rawUsers.map(user => [String(user._id), user])) + + labels.forEach(label => { + const user = users.get(label.user_id) + label.user_display_name = _displayNameForUser(user) + }) + return labels +} + +function _displayNameForUser(user) { + if (user == null) { + return 'Anonymous' + } + if (user.name) { + return user.name + } + let name = [user.first_name, user.last_name] + .filter(n => n != null) + .join(' ') + .trim() + if (name === '') { + name = user.email.split('@')[0] + } + if (!name) { + return '?' + } + return name +} + +async function deleteLabel(req, res, next) { + const { Project_id: projectId, label_id: labelId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + + const project = await ProjectGetter.promises.getProject(projectId, { + owner_ref: true, + }) + + // If the current user is the project owner, we can use the non-user-specific + // delete label endpoint. Otherwise, we have to use the user-specific version + // (which only deletes the label if it is owned by the user) + const deleteEndpointUrl = project.owner_ref.equals(userId) + ? `${settings.apis.project_history.url}/project/${projectId}/labels/${labelId}` + : `${settings.apis.project_history.url}/project/${projectId}/user/${userId}/labels/${labelId}` + + await fetchNothing(deleteEndpointUrl, { + method: 'DELETE', + }) + res.sendStatus(204) +} + +async function downloadZipOfVersion(req, res, next) { + const { project_id: projectId, version } = req.params + + const project = await ProjectDetailsHandler.promises.getDetails(projectId) + const v1Id = + project.overleaf && project.overleaf.history && project.overleaf.history.id + + if (v1Id == null) { + logger.error( + { projectId, version }, + 'got request for zip version of non-v1 history project' + ) + return res.sendStatus(402) + } + + await _pipeHistoryZipToResponse( + v1Id, + version, + `${project.name} (Version ${version})`, + req, + res + ) +} + +async function _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res) { + if (req.destroyed) { + // client has disconnected -- skip project history api call and download + return + } + // increase timeout to 6 minutes + res.setTimeout(6 * 60 * 1000) + const url = `${settings.apis.v1_history.url}/projects/${v1ProjectId}/version/${version}/zip` + const basicAuth = { + user: settings.apis.v1_history.user, + password: settings.apis.v1_history.pass, + } + + if (!Features.hasFeature('saas')) { + let stream + try { + stream = await fetchStream(url, { basicAuth }) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + return res.sendStatus(404) + } else { + throw err + } + } + + prepareZipAttachment(res, `${name}.zip`) + + try { + await pipeline(stream, res) + } catch (err) { // If the downstream request is cancelled, we get an // ERR_STREAM_PREMATURE_CLOSE. - if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') { - logger.warn({ url, err }, 'history API error') - next(err) + if (!isPrematureClose(err)) { + throw err } + } + return + } + + let body + try { + body = await fetchJson(url, { method: 'POST', basicAuth }) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + throw new Errors.NotFoundError('zip not found') + } else { + throw err + } + } + + if (req.destroyed) { + // client has disconnected -- skip delayed s3 download + return + } + + if (!body.zipUrl) { + throw new OError('Missing zipUrl, cannot fetch zip file', { + v1ProjectId, + body, }) - }, + } - proxyToHistoryApiAndInjectUserDetails(req, res, next) { - const userId = SessionManager.getLoggedInUserId(req.session) - const url = settings.apis.project_history.url + req.url - HistoryController._makeRequest( - { - url, - method: req.method, - json: true, - headers: { - 'X-User-Id': userId, - }, - }, - function (err, body) { - if (err) { - return next(err) - } - HistoryManager.injectUserDetails(body, function (err, data) { - if (err) { - return next(err) - } - res.json(data) - }) - } - ) - }, + // retry for about 6 minutes starting with short delay + let retryDelay = 2000 + let attempt = 0 + while (true) { + attempt += 1 + await setTimeout(retryDelay) - resyncProjectHistory(req, res, next) { - // increase timeout to 6 minutes - res.setTimeout(6 * 60 * 1000) - const projectId = req.params.Project_id - const opts = {} - const historyRangesMigration = req.body.historyRangesMigration - if (historyRangesMigration) { - opts.historyRangesMigration = historyRangesMigration - } - if (req.body.resyncProjectStructureOnly) { - opts.resyncProjectStructureOnly = req.body.resyncProjectStructureOnly - } - ProjectEntityUpdateHandler.resyncProjectHistory( - projectId, - opts, - function (err) { - if (err instanceof Errors.ProjectHistoryDisabledError) { - return res.sendStatus(404) - } - if (err) { - return next(err) - } - res.sendStatus(204) - } - ) - }, - - restoreFileFromV2(req, res, next) { - const { project_id: projectId } = req.params - const { version, pathname } = req.body - const userId = SessionManager.getLoggedInUserId(req.session) - RestoreManager.restoreFileFromV2( - userId, - projectId, - version, - pathname, - function (err, entity) { - if (err) { - return next(err) - } - res.json({ - type: entity.type, - id: entity._id, - }) - } - ) - }, - - revertFile(req, res, next) { - const { project_id: projectId } = req.params - const { version, pathname } = req.body - const userId = SessionManager.getLoggedInUserId(req.session) - RestoreManager.revertFile( - userId, - projectId, - version, - pathname, - {}, - function (err, entity) { - if (err) { - return next(err) - } - res.json({ - type: entity.type, - id: entity._id, - }) - } - ) - }, - - revertProject(req, res, next) { - const { project_id: projectId } = req.params - const { version } = req.body - const userId = SessionManager.getLoggedInUserId(req.session) - RestoreManager.revertProject(userId, projectId, version, function (err) { - if (err) { - return next(err) - } - res.sendStatus(200) - }) - }, - - getLabels(req, res, next) { - const projectId = req.params.Project_id - HistoryController._makeRequest( - { - method: 'GET', - url: `${settings.apis.project_history.url}/project/${projectId}/labels`, - json: true, - }, - function (err, labels) { - if (err) { - return next(err) - } - HistoryController._enrichLabels(labels, (err, labels) => { - if (err) { - return next(err) - } - res.json(labels) - }) - } - ) - }, - - createLabel(req, res, next) { - const projectId = req.params.Project_id - const { comment, version } = req.body - const userId = SessionManager.getLoggedInUserId(req.session) - HistoryController._makeRequest( - { - method: 'POST', - url: `${settings.apis.project_history.url}/project/${projectId}/labels`, - json: { comment, version, user_id: userId }, - }, - function (err, label) { - if (err) { - return next(err) - } - HistoryController._enrichLabel(label, (err, label) => { - if (err) { - return next(err) - } - res.json(label) - }) - } - ) - }, - - _enrichLabel(label, callback) { - if (!label.user_id) { - const newLabel = Object.assign({}, label) - newLabel.user_display_name = HistoryController._displayNameForUser(null) - return callback(null, newLabel) - } - UserGetter.getUser( - label.user_id, - { first_name: 1, last_name: 1, email: 1 }, - (err, user) => { - if (err) { - return callback(err) - } - const newLabel = Object.assign({}, label) - newLabel.user_display_name = HistoryController._displayNameForUser(user) - callback(null, newLabel) - } - ) - }, - - _enrichLabels(labels, callback) { - if (!labels || !labels.length) { - return callback(null, []) - } - const uniqueUsers = new Set(labels.map(label => label.user_id)) - - // For backwards compatibility, and for anonymously created labels in SP - // expect missing user_id fields - uniqueUsers.delete(undefined) - - if (!uniqueUsers.size) { - return callback(null, labels) - } - - UserGetter.getUsers( - Array.from(uniqueUsers), - { first_name: 1, last_name: 1, email: 1 }, - function (err, rawUsers) { - if (err) { - return callback(err) - } - const users = new Map(rawUsers.map(user => [String(user._id), user])) - - labels.forEach(label => { - const user = users.get(label.user_id) - label.user_display_name = HistoryController._displayNameForUser(user) - }) - callback(null, labels) - } - ) - }, - - _displayNameForUser(user) { - if (user == null) { - return 'Anonymous' - } - if (user.name) { - return user.name - } - let name = [user.first_name, user.last_name] - .filter(n => n != null) - .join(' ') - .trim() - if (name === '') { - name = user.email.split('@')[0] - } - if (!name) { - return '?' - } - return name - }, - - deleteLabel(req, res, next) { - const { Project_id: projectId, label_id: labelId } = req.params - const userId = SessionManager.getLoggedInUserId(req.session) - - ProjectGetter.getProject( - projectId, - { - owner_ref: true, - }, - (err, project) => { - if (err) { - return next(err) - } - - // If the current user is the project owner, we can use the non-user-specific delete label endpoint. - // Otherwise, we have to use the user-specific version (which only deletes the label if it is owned by the user) - const deleteEndpointUrl = project.owner_ref.equals(userId) - ? `${settings.apis.project_history.url}/project/${projectId}/labels/${labelId}` - : `${settings.apis.project_history.url}/project/${projectId}/user/${userId}/labels/${labelId}` - - HistoryController._makeRequest( - { - method: 'DELETE', - url: deleteEndpointUrl, - }, - function (err) { - if (err) { - return next(err) - } - res.sendStatus(204) - } - ) - } - ) - }, - - _makeRequest(options, callback) { - return request(options, function (err, response, body) { - if (err) { - return callback(err) - } - if (response.statusCode >= 200 && response.statusCode < 300) { - callback(null, body) - } else { - err = new Error( - `history api responded with non-success code: ${response.statusCode}` - ) - callback(err) - } - }) - }, - - downloadZipOfVersion(req, res, next) { - const { project_id: projectId, version } = req.params - ProjectDetailsHandler.getDetails(projectId, function (err, project) { - if (err) { - return next(err) - } - const v1Id = - project.overleaf && - project.overleaf.history && - project.overleaf.history.id - if (v1Id == null) { - logger.error( - { projectId, version }, - 'got request for zip version of non-v1 history project' - ) - return res.sendStatus(402) - } - HistoryController._pipeHistoryZipToResponse( - v1Id, - version, - `${project.name} (Version ${version})`, - req, - res, - next - ) - }) - }, - - _pipeHistoryZipToResponse(v1ProjectId, version, name, req, res, next) { if (req.destroyed) { - // client has disconnected -- skip project history api call and download - return - } - // increase timeout to 6 minutes - res.setTimeout(6 * 60 * 1000) - const url = `${settings.apis.v1_history.url}/projects/${v1ProjectId}/version/${version}/zip` - const options = { - auth: { - user: settings.apis.v1_history.user, - pass: settings.apis.v1_history.pass, - }, - json: true, - url, - } - - if (!Features.hasFeature('saas')) { - const getReq = request({ ...options, method: 'get' }) - - getReq.on('error', function (err) { - logger.warn({ err, v1ProjectId, version }, 'history zip download error') - res.sendStatus(500) - }) - getReq.on('response', function (response) { - const statusCode = response.statusCode - if (statusCode !== 200) { - logger.warn( - { v1ProjectId, version, statusCode }, - 'history zip download failed' - ) - if (statusCode === 404) { - res.sendStatus(404) - } else { - res.sendStatus(500) - } - return - } - - prepareZipAttachment(res, `${name}.zip`) - pipeline(response, res, function (err) { - // If the downstream request is cancelled, we get an - // ERR_STREAM_PREMATURE_CLOSE. - if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') { - logger.error({ err, v1ProjectId, version }, 'history API error') - next(err) - } - }) - }) + // client has disconnected -- skip s3 download return } - request({ ...options, method: 'post' }, function (err, response, body) { - if (err) { - OError.tag(err, 'history API error', { - v1ProjectId, - version, - }) - return next(err) + // increase delay by 1 second up to 10 + if (retryDelay < 10000) { + retryDelay += 1000 + } + + try { + const stream = await fetchStream(body.zipUrl) + prepareZipAttachment(res, `${name}.zip`) + await pipeline(stream, res) + } catch (err) { + if (attempt > MAX_HISTORY_ZIP_ATTEMPTS) { + throw err } - if (response.statusCode !== 200) { - if (response.statusCode === 404) { - return next(new Errors.NotFoundError('zip not found')) - } else { - return next( - new OError('Error while getting zip for download', { - v1ProjectId, - statusCode: response.statusCode, - }) - ) - } - } - if (req.destroyed) { - // client has disconnected -- skip delayed s3 download - return - } - if (!body.zipUrl) { - return next( - new OError('Missing zipUrl, cannot fetch zip file', { - v1ProjectId, - body, - statusCode: response.statusCode, - }) + + if (err instanceof RequestFailedError && err.response.status === 404) { + // File not ready yet. Retry. + continue + } else if (isPrematureClose(err)) { + // Downstream request cancelled. Retry. + continue + } else { + // Unknown error. Log and retry. + logger.warn( + { err, v1ProjectId, version, retryAttempt: attempt }, + 'history s3 proxying error' ) + continue } - let retryAttempt = 0 - let retryDelay = 2000 - // retry for about 6 minutes starting with short delay - async.retry( - 40, - callback => - setTimeout(function () { - if (req.destroyed) { - // client has disconnected -- skip s3 download - return callback() // stop async.retry loop - } + } - // increase delay by 1 second up to 10 - if (retryDelay < 10000) { - retryDelay += 1000 - } - retryAttempt++ - const getReq = request({ - url: body.zipUrl, - sendImmediately: true, - }) - const abortS3Request = () => getReq.abort() - req.on('close', abortS3Request) - res.on('timeout', abortS3Request) - function cleanupAbortTrigger() { - req.off('close', abortS3Request) - res.off('timeout', abortS3Request) - } - getReq.on('response', function (response) { - if (response.statusCode !== 200) { - cleanupAbortTrigger() - return callback(new Error('invalid response')) - } - // pipe also proxies the headers, but we want to customize these ones - delete response.headers['content-disposition'] - delete response.headers['content-type'] - res.status(response.statusCode) - prepareZipAttachment(res, `${name}.zip`) - pipeline(response, res, err => { - // If the downstream request is cancelled, we get an - // ERR_STREAM_PREMATURE_CLOSE. - if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') { - logger.warn( - { err, v1ProjectId, version, retryAttempt }, - 'history s3 proxying error' - ) - } - }) - callback() - }) - getReq.on('error', function (err) { - logger.warn( - { err, v1ProjectId, version, retryAttempt }, - 'history s3 download error' - ) - cleanupAbortTrigger() - callback(err) - }) - }, retryDelay), - function (err) { - if (err) { - OError.tag(err, 'history s3 download failed', { - v1ProjectId, - version, - retryAttempt, - }) - next(err) - } - } - ) - }) + // We made it through. No need to retry anymore. Exit loop + break + } +} + +async function getLatestHistory(req, res, next) { + const projectId = req.params.project_id + const history = await HistoryManager.promises.getLatestHistory(projectId) + res.json(history) +} + +async function getChanges(req, res, next) { + const projectId = req.params.project_id + const since = req.query.since + const changes = await HistoryManager.promises.getChanges(projectId, { since }) + res.json(changes) +} + +function isPrematureClose(err) { + return ( + err instanceof Error && + 'code' in err && + err.code === 'ERR_STREAM_PREMATURE_CLOSE' + ) +} + +module.exports = { + getBlob: expressify(getBlob), + headBlob: expressify(headBlob), + proxyToHistoryApi: expressify(proxyToHistoryApi), + proxyToHistoryApiAndInjectUserDetails: expressify( + proxyToHistoryApiAndInjectUserDetails + ), + resyncProjectHistory: expressify(resyncProjectHistory), + restoreFileFromV2: expressify(restoreFileFromV2), + revertFile: expressify(revertFile), + revertProject: expressify(revertProject), + getLabels: expressify(getLabels), + createLabel: expressify(createLabel), + deleteLabel: expressify(deleteLabel), + downloadZipOfVersion: expressify(downloadZipOfVersion), + getLatestHistory: expressify(getLatestHistory), + getChanges: expressify(getChanges), + _displayNameForUser, + promises: { + _pipeHistoryZipToResponse, }, } diff --git a/services/web/app/src/Features/History/HistoryManager.js b/services/web/app/src/Features/History/HistoryManager.js index 78730c4782..6e40907d1b 100644 --- a/services/web/app/src/Features/History/HistoryManager.js +++ b/services/web/app/src/Features/History/HistoryManager.js @@ -21,6 +21,12 @@ const projectKey = require('./project_key') const GLOBAL_BLOBS = new Set() // CHANGE FROM SOURCE: only store hashes. +const HISTORY_V1_URL = settings.apis.v1_history.url +const HISTORY_V1_BASIC_AUTH = { + user: settings.apis.v1_history.user, + password: settings.apis.v1_history.pass, +} + function makeGlobalKey(hash) { return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}` } @@ -144,16 +150,10 @@ async function _deleteProjectInProjectHistory(projectId) { async function _deleteProjectInFullProjectHistory(historyId) { try { - await fetchNothing( - `${settings.apis.v1_history.url}/projects/${historyId}`, - { - method: 'DELETE', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, - } - ) + await fetchNothing(`${HISTORY_V1_URL}/projects/${historyId}`, { + method: 'DELETE', + basicAuth: HISTORY_V1_BASIC_AUTH, + }) } catch (err) { throw OError.tag(err, 'failed to clear project history', { historyId }) } @@ -162,29 +162,23 @@ async function _deleteProjectInFullProjectHistory(historyId) { async function uploadBlobFromDisk(historyId, hash, byteLength, fsPath) { const outStream = fs.createReadStream(fsPath) - const url = `${settings.apis.v1_history.url}/projects/${historyId}/blobs/${hash}` + const url = `${HISTORY_V1_URL}/projects/${historyId}/blobs/${hash}` await fetchNothing(url, { method: 'PUT', body: outStream, headers: { 'Content-Length': byteLength }, // add the content length to work around problems with chunked encoding in node 18 signal: AbortSignal.timeout(60 * 1000), - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, }) } async function copyBlob(sourceHistoryId, targetHistoryId, hash) { - const url = `${settings.apis.v1_history.url}/projects/${targetHistoryId}/blobs/${hash}` + const url = `${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}` await fetchNothing( `${url}?${new URLSearchParams({ copyFrom: sourceHistoryId })}`, { method: 'POST', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, } ) } @@ -200,7 +194,7 @@ async function requestBlobWithFallback( 'overleaf.history.id': true, }) // Talk to history-v1 directly to avoid streaming via project-history. - let url = new URL(settings.apis.v1_history.url) + let url = new URL(HISTORY_V1_URL) url.pathname += `/projects/${project.overleaf.history.id}/blobs/${hash}` const opts = { method, headers: { Range: range } } @@ -255,22 +249,14 @@ async function requestBlobWithFallback( * @returns Promise */ async function getCurrentContent(projectId) { - const project = await ProjectGetter.promises.getProject(projectId, { - overleaf: true, - }) - const historyId = project?.overleaf?.history?.id - if (!historyId) { - throw new OError('project does not have a history id', { projectId }) - } + const historyId = await getHistoryId(projectId) + try { return await fetchJson( - `${settings.apis.v1_history.url}/projects/${historyId}/latest/content`, + `${HISTORY_V1_URL}/projects/${historyId}/latest/content`, { method: 'GET', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, } ) } catch (err) { @@ -287,22 +273,14 @@ async function getCurrentContent(projectId) { * @returns Promise */ async function getContentAtVersion(projectId, version) { - const project = await ProjectGetter.promises.getProject(projectId, { - overleaf: true, - }) - const historyId = project?.overleaf?.history?.id - if (!historyId) { - throw new OError('project does not have a history id', { projectId }) - } + const historyId = await getHistoryId(projectId) + try { return await fetchJson( - `${settings.apis.v1_history.url}/projects/${historyId}/versions/${version}/content`, + `${HISTORY_V1_URL}/projects/${historyId}/versions/${version}/content`, { method: 'GET', - basicAuth: { - user: settings.apis.v1_history.user, - password: settings.apis.v1_history.pass, - }, + basicAuth: HISTORY_V1_BASIC_AUTH, } ) } catch (err) { @@ -314,6 +292,53 @@ async function getContentAtVersion(projectId, version) { } } +/** + * Get the latest chunk from history + * + * @param {string} projectId + */ +async function getLatestHistory(projectId) { + const historyId = await getHistoryId(projectId) + + return await fetchJson( + `${HISTORY_V1_URL}/projects/${historyId}/latest/history`, + { + basicAuth: HISTORY_V1_BASIC_AUTH, + } + ) +} + +/** + * Get history changes since a given version + * + * @param {string} projectId + * @param {object} opts + * @param {number} opts.since - The start version of changes to get + */ +async function getChanges(projectId, opts = {}) { + const historyId = await getHistoryId(projectId) + + const url = new URL(`${HISTORY_V1_URL}/projects/${historyId}/changes`) + if (opts.since) { + url.searchParams.set('since', opts.since) + } + + return await fetchJson(url, { + basicAuth: HISTORY_V1_BASIC_AUTH, + }) +} + +async function getHistoryId(projectId) { + const project = await ProjectGetter.promises.getProject(projectId, { + overleaf: true, + }) + const historyId = project?.overleaf?.history?.id + if (!historyId) { + throw new OError('project does not have a history id', { projectId }) + } + return historyId +} + async function injectUserDetails(data) { // data can be either: // { @@ -404,6 +429,8 @@ module.exports = { uploadBlobFromDisk: callbackify(uploadBlobFromDisk), copyBlob: callbackify(copyBlob), requestBlobWithFallback: callbackify(requestBlobWithFallback), + getLatestHistory: callbackify(getLatestHistory), + getChanges: callbackify(getChanges), promises: { loadGlobalBlobs, initializeProject, @@ -417,5 +444,7 @@ module.exports = { uploadBlobFromDisk, copyBlob, requestBlobWithFallback, + getLatestHistory, + getChanges, }, } diff --git a/services/web/app/src/Features/History/HistoryRouter.mjs b/services/web/app/src/Features/History/HistoryRouter.mjs index 5b37236166..d5c7b46804 100644 --- a/services/web/app/src/Features/History/HistoryRouter.mjs +++ b/services/web/app/src/Features/History/HistoryRouter.mjs @@ -151,15 +151,28 @@ function apply(webRouter, privateApiRouter) { webRouter.get( '/project/:project_id/latest/history', + validate({ + params: Joi.object({ + project_id: Joi.objectId().required(), + }), + }), AuthorizationMiddleware.blockRestrictedUserFromProject, AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApi + HistoryController.getLatestHistory ) webRouter.get( '/project/:project_id/changes', + validate({ + params: Joi.object({ + project_id: Joi.objectId().required(), + }), + query: Joi.object({ + since: Joi.number().integer().min(0).optional(), + }), + }), AuthorizationMiddleware.blockRestrictedUserFromProject, AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApi + HistoryController.getChanges ) } diff --git a/services/web/app/src/Features/History/RestoreManager.js b/services/web/app/src/Features/History/RestoreManager.js index f36f1466af..8c73695eed 100644 --- a/services/web/app/src/Features/History/RestoreManager.js +++ b/services/web/app/src/Features/History/RestoreManager.js @@ -33,7 +33,8 @@ const RestoreManager = { } const parentFolderId = await RestoreManager._findOrCreateFolder( projectId, - dirname + dirname, + userId ) const addEntityWithName = async name => await FileSystemImportManager.promises.addEntity( @@ -71,7 +72,8 @@ const RestoreManager = { } const parentFolderId = await RestoreManager._findOrCreateFolder( projectId, - dirname + dirname, + userId ) const file = await ProjectLocator.promises .findElementByPath({ @@ -264,10 +266,11 @@ const RestoreManager = { } }, - async _findOrCreateFolder(projectId, dirname) { + async _findOrCreateFolder(projectId, dirname, userId) { const { lastFolder } = await EditorController.promises.mkdirp( projectId, - dirname + dirname, + userId ) return lastFolder?._id }, diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 8fd4668468..f033436fdd 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -49,7 +49,7 @@ const Modules = require('../../infrastructure/Modules') const UserGetter = require('../User/UserGetter') const { isStandaloneAiAddOnPlanCode, -} = require('../Subscription/RecurlyEntities') +} = require('../Subscription/PaymentProviderEntities') const SubscriptionController = require('../Subscription/SubscriptionController.js') const { formatCurrency } = require('../../util/currency') @@ -338,6 +338,8 @@ const _ProjectController = { 'external-socket-heartbeat', 'full-project-search', 'null-test-share-modal', + 'fall-back-to-clsi-cache', + 'initial-compile-from-clsi-cache', 'pdf-caching-cached-url-lookup', 'pdf-caching-mode', 'pdf-caching-prefetch-large', @@ -347,15 +349,14 @@ const _ProjectController = { !anonymous && 'ro-mirror-on-client', 'track-pdf-download', !anonymous && 'writefull-oauth-promotion', - 'write-and-cite', - 'write-and-cite-ars', - 'default-visual-for-beginners', 'hotjar', 'reviewer-role', - 'papers-integration', 'editor-redesign', 'paywall-change-compile-timeout', 'overleaf-assist-bundle', + 'wf-feature-rebrand', + 'word-count-client', + 'editor-popup-ux-survey', ].filter(Boolean) const getUserValues = async userId => @@ -364,7 +365,7 @@ const _ProjectController = { user: (async () => { const user = await User.findById( userId, - 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram completedTutorials writefull aiErrorAssistant' + 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsExperiments completedTutorials writefull aiErrorAssistant' ).exec() // Handle case of deleted user if (!user) { @@ -404,13 +405,6 @@ const _ProjectController = { userId, projectId ), - usedLatex: OnboardingDataCollectionManager.getOnboardingDataValue( - userId, - 'usedLatex' - ).catch(err => { - logger.error({ err, userId }) - return null - }), odcRole: OnboardingDataCollectionManager.getOnboardingDataValue( userId, 'role' @@ -470,7 +464,6 @@ const _ProjectController = { subscription, isTokenMember, isInvitedMember, - usedLatex, odcRole, } = userValues @@ -567,10 +560,11 @@ const _ProjectController = { .catch(err => logger.error({ err }, 'failed to update split test info in session') ) + + const ownerFeatures = await UserGetter.promises.getUserFeatures( + project.owner_ref + ) if (userId) { - const ownerFeatures = await UserGetter.promises.getUserFeatures( - project.owner_ref - ) const planLimit = ownerFeatures?.collaborators || 0 const namedEditors = project.collaberator_refs?.length || 0 const pendingEditors = project.pendingEditor_refs?.length || 0 @@ -796,15 +790,19 @@ const _ProjectController = { let planCode = subscription?.planCode if (!planCode && !userInNonIndividualSub) { - planCode = 'free' + planCode = 'personal' } + const planDetails = Settings.plans.find(p => p.planCode === planCode) + res.render(template, { title: project.name, priority_title: true, bodyClasses: ['editor'], project_id: project._id, projectName: project.name, + projectOwnerHasPremiumOnPageLoad: + ownerFeatures?.compileGroup === 'priority', user: { id: userId, email: user.email, @@ -829,6 +827,8 @@ const _ProjectController = { inactiveTutorials: TutorialHandler.getInactiveTutorials(user), isAdmin: hasAdminAccess(user), planCode, + planName: planDetails?.name, + isAnnualPlan: planCode && planDetails?.annual, isMemberOfGroupSubscription: userIsMemberOfGroupSubscription, hasInstitutionLicence: userHasInstitutionLicence, }, @@ -847,6 +847,7 @@ const _ProjectController = { referencesSearchMode: user.ace.referencesSearchMode, enableNewEditor: user.ace.enableNewEditor ?? true, }, + labsExperiments: user.labsExperiments ?? [], privilegeLevel, anonymous, isTokenMember, @@ -887,12 +888,6 @@ const _ProjectController = { fixedSizeDocument: true, hasTrackChangesFeature: Features.hasFeature('track-changes'), projectTags, - usedLatex: - // only use the usedLatex value if the split test is enabled - splitTestAssignments['default-visual-for-beginners']?.variant === - 'enabled' - ? usedLatex - : null, odcRole: // only use the ODC role value if the split test is enabled splitTestAssignments['paywall-change-compile-timeout']?.variant === @@ -943,7 +938,7 @@ const _ProjectController = { return plansData }, - async _getAddonPrices(req, res, addonPlans = ['assistBundle']) { + async _getAddonPrices(req, res, addonPlans = ['assistant']) { const plansData = {} const locale = req.i18n.language diff --git a/services/web/app/src/Features/Project/ProjectDeleter.js b/services/web/app/src/Features/Project/ProjectDeleter.js index 62d893dd0f..c5dcafd335 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.js +++ b/services/web/app/src/Features/Project/ProjectDeleter.js @@ -80,7 +80,12 @@ async function unmarkAsDeletedByExternalSource(projectId) { async function deleteUsersProjects(userId) { const projects = await Project.find({ owner_ref: userId }).exec() + logger.info( + { userId, projectCount: projects.length }, + 'found user projects to delete' + ) await promiseMapWithLimit(5, projects, project => deleteProject(project._id)) + logger.info({ userId }, 'deleted all user projects') await CollaboratorsHandler.promises.removeUserFromAllProjects(userId) } diff --git a/services/web/app/src/Features/Project/ProjectDuplicator.js b/services/web/app/src/Features/Project/ProjectDuplicator.js index da18c7e9b8..47c00ed3df 100644 --- a/services/web/app/src/Features/Project/ProjectDuplicator.js +++ b/services/web/app/src/Features/Project/ProjectDuplicator.js @@ -21,6 +21,7 @@ const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher') const _ = require('lodash') const TagsHandler = require('../Tags/TagsHandler') const Features = require('../../infrastructure/Features') +const ClsiCacheManager = require('../Compile/ClsiCacheManager') module.exports = { duplicate: callbackify(duplicate), @@ -35,6 +36,7 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) { originalProjectId, { compiler: true, + imageName: true, rootFolder: true, rootDoc_id: true, fromV1TemplateId: true, @@ -73,6 +75,21 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) { { segmentation } ) + let prepareClsiCacheInBackground = Promise.resolve() + if (originalProject.imageName === newProject.imageName) { + // Populate the clsi-cache unless the TeXLive release has changed. + prepareClsiCacheInBackground = ClsiCacheManager.prepareClsiCache( + newProject._id, + owner._id, + { sourceProjectId: originalProjectId } + ).catch(err => { + logger.warn( + { err, originalProjectId, projectId: newProject._id }, + 'failed to prepare clsi-cache for cloned project' + ) + }) + } + try { await ProjectOptionsHandler.promises.setCompiler( newProject._id, @@ -120,6 +137,10 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) { }) } + try { + await prepareClsiCacheInBackground + } catch {} + return newProject } diff --git a/services/web/app/src/Features/Project/ProjectEntityHandler.js b/services/web/app/src/Features/Project/ProjectEntityHandler.js index b834f58d2a..7d0498ffd0 100644 --- a/services/web/app/src/Features/Project/ProjectEntityHandler.js +++ b/services/web/app/src/Features/Project/ProjectEntityHandler.js @@ -99,10 +99,18 @@ function getAllDocPathsFromProject(project) { return docPath } -async function getDoc(projectId, docId) { +/** + * + * @param {string} projectId + * @param {string} docId + * @param {{peek?: boolean, include_deleted?: boolean}} options + * @return {Promise<{lines: *, rev: *, version: *, ranges: *}>} + */ +async function getDoc(projectId, docId, options = {}) { const { lines, rev, version, ranges } = await DocstoreManager.promises.getDoc( projectId, - docId + docId, + options ) return { lines, rev, version, ranges } } diff --git a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js index 31b3efaec8..84002f1a38 100644 --- a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js +++ b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js @@ -105,7 +105,7 @@ function wrapWithLock(methodWithoutLock) { return methodWithLock } -async function addDoc(projectId, folderId, doc) { +async function addDoc(projectId, folderId, doc, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { @@ -119,12 +119,13 @@ async function addDoc(projectId, folderId, doc) { project, folderId, doc, - 'doc' + 'doc', + userId ) return { result, project: newProject } } -async function addFile(projectId, folderId, fileRef) { +async function addFile(projectId, folderId, fileRef, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { rootFolder: true, name: true, overleaf: true } @@ -134,23 +135,24 @@ async function addFile(projectId, folderId, fileRef) { project, folderId, fileRef, - 'file' + 'file', + userId ) return { result, project: newProject } } -async function addFolder(projectId, parentFolderId, folderName) { +async function addFolder(projectId, parentFolderId, folderName, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { rootFolder: true, name: true, overleaf: true } ) parentFolderId = _confirmFolder(project, parentFolderId) const folder = new Folder({ name: folderName }) - await _putElement(project, parentFolderId, folder, 'folder') + await _putElement(project, parentFolderId, folder, 'folder', userId) return { folder, parentFolderId } } -async function replaceFileWithNew(projectId, fileId, newFileRef) { +async function replaceFileWithNew(projectId, fileId, newFileRef, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { rootFolder: true, name: true, overleaf: true } @@ -169,6 +171,8 @@ async function replaceFileWithNew(projectId, fileId, newFileRef) { [`${path.mongo}.created`]: new Date(), [`${path.mongo}.linkedFileData`]: newFileRef.linkedFileData, [`${path.mongo}.hash`]: newFileRef.hash, + lastUpdated: new Date(), + lastUpdatedBy: userId, }, $inc: { version: 1, @@ -194,7 +198,7 @@ async function replaceFileWithNew(projectId, fileId, newFileRef) { return { oldFileRef: fileRef, project, path, newProject, newFileRef } } -async function replaceDocWithFile(projectId, docId, fileRef) { +async function replaceDocWithFile(projectId, docId, fileRef, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { rootFolder: true, name: true, overleaf: true } @@ -215,6 +219,7 @@ async function replaceDocWithFile(projectId, docId, fileRef) { [`${folderMongoPath}.fileRefs`]: fileRef, }, $inc: { version: 1 }, + $set: { lastUpdated: new Date(), lastUpdatedBy: userId }, }, { new: true } ).exec() @@ -227,7 +232,7 @@ async function replaceDocWithFile(projectId, docId, fileRef) { return newProject } -async function replaceFileWithDoc(projectId, fileId, newDoc) { +async function replaceFileWithDoc(projectId, fileId, newDoc, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { rootFolder: true, name: true, overleaf: true } @@ -248,6 +253,7 @@ async function replaceFileWithDoc(projectId, fileId, newDoc) { [`${folderMongoPath}.docs`]: newDoc, }, $inc: { version: 1 }, + $set: { lastUpdated: new Date(), lastUpdatedBy: userId }, }, { new: true } ).exec() @@ -260,7 +266,7 @@ async function replaceFileWithDoc(projectId, fileId, newDoc) { return newProject } -async function mkdirp(projectId, path, options = {}) { +async function mkdirp(projectId, path, userId, options = {}) { // defaults to case insensitive paths, use options {exactCaseMatch:true} // to make matching case-sensitive const folders = path.split('/').filter(folder => folder.length !== 0) @@ -289,7 +295,7 @@ async function mkdirp(projectId, path, options = {}) { // Folder couldn't be found. Create it. const parentFolderId = lastFolder && lastFolder._id const { folder: newFolder, parentFolderId: newParentFolderId } = - await addFolder(projectId, parentFolderId, folderName) + await addFolder(projectId, parentFolderId, folderName, userId) newFolder.parentFolder_id = newParentFolderId lastFolder = newFolder newFolders.push(newFolder) @@ -298,7 +304,13 @@ async function mkdirp(projectId, path, options = {}) { return { folder: lastFolder, newFolders } } -async function moveEntity(projectId, entityId, destFolderId, entityType) { +async function moveEntity( + projectId, + entityId, + destFolderId, + entityType, + userId +) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { rootFolder: true, name: true, overleaf: true } @@ -326,7 +338,8 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) { project, destFolderId, entity, - entityType + entityType, + userId ) // Note: putElement always pushes onto the end of an // array so it will never change an existing mongo @@ -337,10 +350,10 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) { // is done by _checkValidMove above) because that // would lead to it being deleted. const newProject = await _removeElementFromMongoArray( - Project, projectId, entityPath.mongo, - entityId + entityId, + userId ) const { docs: newDocs, files: newFiles } = ProjectEntityHandler.getAllEntitiesFromProject(newProject) @@ -375,7 +388,7 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) { return { project, startPath, endPath, rev: entity.rev, changes } } -async function deleteEntity(projectId, entityId, entityType) { +async function deleteEntity(projectId, entityId, entityType, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { name: true, rootFolder: true, overleaf: true, rootDoc_id: true } @@ -399,22 +412,16 @@ async function deleteEntity(projectId, entityId, entityType) { type: entityType, }) const newProject = await _removeElementFromMongoArray( - Project, projectId, path.mongo, entityId, + userId, deleteRootDoc ) return { entity, path, projectBeforeDeletion: project, newProject } } -async function renameEntity( - projectId, - entityId, - entityType, - newName, - callback -) { +async function renameEntity(projectId, entityId, entityType, newName, userId) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { rootFolder: true, name: true, overleaf: true } @@ -445,7 +452,14 @@ async function renameEntity( // we need to increment the project version number for any structure change const newProject = await Project.findOneAndUpdate( { _id: projectId, [entPath.mongo]: { $exists: true } }, - { $set: { [`${entPath.mongo}.name`]: newName }, $inc: { version: 1 } }, + { + $set: { + [`${entPath.mongo}.name`]: newName, + lastUpdated: new Date(), + lastUpdatedBy: userId, + }, + $inc: { version: 1 }, + }, { new: true } ).exec() if (newProject == null) { @@ -478,10 +492,10 @@ async function _insertDeletedFileReference(projectId, fileRef) { } async function _removeElementFromMongoArray( - model, modelId, path, elementId, + userId, deleteRootDoc = false ) { const nonArrayPath = path.slice(0, path.lastIndexOf('.')) @@ -490,11 +504,12 @@ async function _removeElementFromMongoArray( const update = { $pull: { [nonArrayPath]: { _id: elementId } }, $inc: { version: 1 }, + $set: { lastUpdated: new Date(), lastUpdatedBy: userId }, } if (deleteRootDoc) { update.$unset = { rootDoc_id: 1 } } - return model.findOneAndUpdate(query, update, options).exec() + return Project.findOneAndUpdate(query, update, options).exec() } function _countElements(project) { @@ -522,7 +537,7 @@ function _countElements(project) { return countFolder(project.rootFolder[0]) } -async function _putElement(project, folderId, element, type) { +async function _putElement(project, folderId, element, type, userId) { if (element == null || element._id == null) { logger.warn( { projectId: project._id, folderId, element, type }, @@ -578,7 +593,11 @@ async function _putElement(project, folderId, element, type) { const mongoPath = `${path.mongo}.${pathSegment}` const newProject = await Project.findOneAndUpdate( { _id: project._id, [path.mongo]: { $exists: true } }, - { $push: { [mongoPath]: element }, $inc: { version: 1 } }, + { + $push: { [mongoPath]: element }, + $inc: { version: 1 }, + $set: { lastUpdated: new Date(), lastUpdatedBy: userId }, + }, { new: true } ).exec() if (newProject == null) { @@ -709,7 +728,11 @@ async function createNewFolderStructure(projectId, docEntries, fileEntries) { 'rootFolder.0.files.0': { $exists: false }, }, { - $set: { rootFolder: [rootFolder] }, + $set: { + rootFolder: [rootFolder], + // NOTE: Do not set lastUpdated/lastUpdatedBy here. They are both set when creating the initial record. + // The newly created clsi-cache record uses the lastUpdated timestamp of the initial record. Updating the lastUpdated timestamp here invalidates the cache record. + }, $inc: { version: 1 }, }, { diff --git a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js index dd86dc4724..585c2d2698 100644 --- a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js +++ b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js @@ -296,7 +296,8 @@ const addDocWithRanges = wrapWithLock({ await ProjectEntityUpdateHandler._addDocAndSendToTpds( projectId, folderId, - doc + doc, + userId ) const docPath = result?.path?.fileSystem const projectHistoryId = project?.overleaf?.history?.id @@ -364,7 +365,8 @@ const addFile = wrapWithLock({ await ProjectEntityUpdateHandler._addFileAndSendToTpds( projectId, folderId, - fileRef + fileRef, + userId ) const projectHistoryId = project.overleaf?.history?.id const newFiles = [ @@ -382,12 +384,6 @@ const addFile = wrapWithLock({ { newFiles, newProject: project }, source ) - - ProjectUpdateHandler.promises - .markAsUpdated(projectId, new Date(), userId) - .catch(error => { - logger.error({ error }, 'failed to mark project as updated') - }) return { fileRef, folderId, createdBlob } }, }) @@ -434,7 +430,8 @@ const upsertDoc = wrapWithLock( await ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc( projectId, existingFile._id, - doc + doc, + userId ) await TpdsUpdateSender.promises.addDoc({ @@ -616,7 +613,8 @@ const upsertFile = wrapWithLock({ await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile( projectId, existingDoc._id, - fileRef + fileRef, + userId ) const projectHistoryId = project.overleaf?.history?.id await TpdsUpdateSender.promises.addFile({ @@ -699,7 +697,8 @@ const upsertDocWithPath = wrapWithLock( const { newFolders, folder } = await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock( projectId, - folderPath + folderPath, + userId ) const { isNew, doc } = await ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock( @@ -772,7 +771,8 @@ const upsertFileWithPath = wrapWithLock({ const { newFolders, folder } = await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock( projectId, - folderPath + folderPath, + userId ) // this calls directly into the upsertFile main task (without the beforeLock part) const { @@ -818,7 +818,8 @@ const deleteEntity = wrapWithLock( await ProjectEntityMongoUpdateHandler.promises.deleteEntity( projectId, entityId, - entityType + entityType, + userId ) const subtreeListing = await ProjectEntityUpdateHandler._cleanUpEntity( projectBeforeDeletion, @@ -866,7 +867,7 @@ const deleteEntityWithPath = wrapWithLock( } ) -const mkdirp = wrapWithLock(async function (projectId, path) { +const mkdirp = wrapWithLock(async function (projectId, path, userId) { for (const folder of path.split('/')) { if (folder.length > 0 && !SafePath.isCleanFilename(folder)) { throw new Errors.InvalidNameError('invalid element name') @@ -875,32 +876,37 @@ const mkdirp = wrapWithLock(async function (projectId, path) { return await ProjectEntityMongoUpdateHandler.promises.mkdirp( projectId, path, + userId, { exactCaseMatch: false } ) }) -const mkdirpWithExactCase = wrapWithLock(async function (projectId, path) { - for (const folder of path.split('/')) { - if (folder.length > 0 && !SafePath.isCleanFilename(folder)) { - throw new Errors.InvalidNameError('invalid element name') +const mkdirpWithExactCase = wrapWithLock( + async function (projectId, path, userId) { + for (const folder of path.split('/')) { + if (folder.length > 0 && !SafePath.isCleanFilename(folder)) { + throw new Errors.InvalidNameError('invalid element name') + } } + return await ProjectEntityMongoUpdateHandler.promises.mkdirp( + projectId, + path, + userId, + { exactCaseMatch: true } + ) } - return await ProjectEntityMongoUpdateHandler.promises.mkdirp( - projectId, - path, - { exactCaseMatch: true } - ) -}) +) const addFolder = wrapWithLock( - async function (projectId, parentFolderId, folderName) { + async function (projectId, parentFolderId, folderName, userId) { if (!SafePath.isCleanFilename(folderName)) { throw new Errors.InvalidNameError('invalid element name') } return await ProjectEntityMongoUpdateHandler.promises.addFolder( projectId, parentFolderId, - folderName + folderName, + userId ) } ) @@ -929,7 +935,8 @@ const moveEntity = wrapWithLock( projectId, entityId, destFolderId, - entityType + entityType, + userId ) const projectHistoryId = project.overleaf?.history?.id @@ -988,7 +995,8 @@ const renameEntity = wrapWithLock( projectId, entityId, entityType, - newName + newName, + userId ) const projectHistoryId = project.overleaf?.history?.id @@ -1115,7 +1123,8 @@ const convertDocToFile = wrapWithLock({ await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile( projectId, doc._id, - fileRef + fileRef, + userId ) const projectHistoryId = project.overleaf?.history?.id await DocumentUpdaterHandler.promises.updateProjectStructure( @@ -1274,16 +1283,18 @@ const ProjectEntityUpdateHandler = { upsertFile, upsertFileWithPath, appendToDocWithPath: appendToDoc, + setMainBibliographyDoc, }, - async _addDocAndSendToTpds(projectId, folderId, doc) { + async _addDocAndSendToTpds(projectId, folderId, doc, userId) { let result, project try { ;({ result, project } = await ProjectEntityMongoUpdateHandler.promises.addDoc( projectId, folderId, - doc + doc, + userId )) } catch (err) { throw OError.tag(err, 'error adding file with project', { @@ -1328,14 +1339,15 @@ const ProjectEntityUpdateHandler = { } }, - async _addFileAndSendToTpds(projectId, folderId, fileRef) { + async _addFileAndSendToTpds(projectId, folderId, fileRef, userId) { let result, project try { ;({ result, project } = await ProjectEntityMongoUpdateHandler.promises.addFile( projectId, folderId, - fileRef + fileRef, + userId )) } catch (err) { throw OError.tag(err, 'error adding file with project', { @@ -1382,7 +1394,8 @@ const ProjectEntityUpdateHandler = { } = await ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew( projectId, fileId, - newFileRef + newFileRef, + userId ) const oldFiles = [ @@ -1410,11 +1423,6 @@ const ProjectEntityUpdateHandler = { projectName: project.name, folderId, }) - ProjectUpdateHandler.promises - .markAsUpdated(projectId, new Date(), userId) - .catch(error => { - logger.error({ error }, 'failed to mark project as updated') - }) await DocumentUpdaterHandler.promises.updateProjectStructure( projectId, @@ -1538,7 +1546,8 @@ const ProjectEntityUpdateHandler = { projectId, entityId, entityType, - rename.newName + rename.newName, + null // unset lastUpdatedBy ) // update the renamed entity for the resync diff --git a/services/web/app/src/Features/Project/ProjectGetter.js b/services/web/app/src/Features/Project/ProjectGetter.js index 7edf08d7a2..5b93cdc861 100644 --- a/services/web/app/src/Features/Project/ProjectGetter.js +++ b/services/web/app/src/Features/Project/ProjectGetter.js @@ -83,6 +83,9 @@ const ProjectGetter = { return project._id }, + /** + * @return {Promise} + */ async findAllUsersProjects(userId, fields) { const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter') const ownedProjects = await Project.find( diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index 6ae7080961..61131ec617 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -115,7 +115,7 @@ async function projectListPage(req, res, next) { }) const user = await User.findById( userId, - `email emails features alphaProgram betaProgram lastPrimaryEmailCheck signUpDate${ + `email emails features alphaProgram betaProgram lastPrimaryEmailCheck signUpDate refProviders${ isSaas ? ' enrollment writefull completedTutorials' : '' }` ) @@ -126,6 +126,8 @@ async function projectListPage(req, res, next) { return } + user.refProviders = _.mapValues(user.refProviders, Boolean) + if (isSaas) { await SplitTestSessionHandler.promises.sessionMaintenance(req, user) @@ -409,13 +411,13 @@ async function projectListPage(req, res, next) { logger.error({ err: error }, 'Failed to get individual subscription') } - // Get the user's assignment for the DS unified nav split test, which - // populates splitTestVariants with a value for the split test name and allows - // Pug to send it to the browser + // Get the user's assignment for the papers notification banner split test, + // which populates splitTestVariants with a value for the split test name and + // allows Pug to send it to the browser await SplitTestHandler.promises.getAssignment( req, res, - 'sidebar-navigation-ui-update' + 'papers-notification-banner' ) res.render('project/list-react', { diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.js b/services/web/app/src/Features/Subscription/FeaturesUpdater.js index 1e7a868fc0..eff88d45fb 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.js +++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.js @@ -14,7 +14,7 @@ const UserGetter = require('../User/UserGetter') const AnalyticsManager = require('../Analytics/AnalyticsManager') const Queues = require('../../infrastructure/Queues') const Modules = require('../../infrastructure/Modules') -const { AI_ADD_ON_CODE } = require('./RecurlyEntities') +const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities') /** * Enqueue a job for refreshing features for the given user @@ -197,6 +197,14 @@ async function doSyncFromV1(v1UserId) { return refreshFeatures(user._id, 'sync-v1') } +async function hasFeaturesViaWritefull(userId) { + const user = await UserGetter.promises.getUser(userId, { + _id: 1, + writefull: 1, + }) + return Boolean(user?.writefull?.isPremium) +} + module.exports = { featuresEpochIsCurrent, computeFeatures: callbackify(computeFeatures), @@ -209,10 +217,12 @@ module.exports = { 'featuresChanged', ]), scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures), + hasFeaturesViaWritefull: callbackify(hasFeaturesViaWritefull), promises: { computeFeatures, refreshFeatures, scheduleRefreshFeatures, doSyncFromV1, + hasFeaturesViaWritefull, }, } diff --git a/services/web/app/src/Features/Subscription/LimitationsManager.js b/services/web/app/src/Features/Subscription/LimitationsManager.js index d0c3d29b7b..464a8bc273 100644 --- a/services/web/app/src/Features/Subscription/LimitationsManager.js +++ b/services/web/app/src/Features/Subscription/LimitationsManager.js @@ -128,7 +128,11 @@ async function userHasSubscription(user) { ) let hasValidSubscription = false if (subscription) { - if (subscription.recurlySubscription_id || subscription.customAccount) { + if ( + subscription.recurlySubscription_id || + subscription.paymentProvider?.subscriptionId || + subscription.customAccount + ) { hasValidSubscription = true } } diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js similarity index 63% rename from services/web/app/src/Features/Subscription/RecurlyEntities.js rename to services/web/app/src/Features/Subscription/PaymentProviderEntities.js index 5c6f700b5f..8cc15f6f2e 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -1,5 +1,9 @@ // @ts-check +/** + * @import { PaymentProvider } from '../../../../types/subscription/dashboard/subscription' + */ + const OError = require('@overleaf/o-error') const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors') const PlansLocator = require('./PlansLocator') @@ -9,7 +13,7 @@ const AI_ADD_ON_CODE = 'assistant' const MEMBERS_LIMIT_ADD_ON_CODE = 'additional-license' const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual'] -class RecurlySubscription { +class PaymentProviderSubscription { /** * @param {object} props * @param {string} props.id @@ -17,7 +21,7 @@ class RecurlySubscription { * @param {string} props.planCode * @param {string} props.planName * @param {number} props.planPrice - * @param {RecurlySubscriptionAddOn[]} [props.addOns] + * @param {PaymentProviderSubscriptionAddOn[]} [props.addOns] * @param {number} props.subtotal * @param {number} [props.taxRate] * @param {number} [props.taxAmount] @@ -26,7 +30,15 @@ class RecurlySubscription { * @param {Date} props.periodStart * @param {Date} props.periodEnd * @param {string} props.collectionMethod - * @param {RecurlySubscriptionChange} [props.pendingChange] + * @param {string} [props.poNumber] + * @param {string} [props.termsAndConditions] + * @param {PaymentProviderSubscriptionChange} [props.pendingChange] + * @param {PaymentProvider['service']} [props.service] + * @param {string} [props.state] + * @param {Date|null} [props.trialPeriodStart] + * @param {Date|null} [props.trialPeriodEnd] + * @param {Date|null} [props.pausePeriodStart] + * @param {number|null} [props.remainingPauseCycles] */ constructor(props) { this.id = props.id @@ -43,7 +55,15 @@ class RecurlySubscription { this.periodStart = props.periodStart this.periodEnd = props.periodEnd this.collectionMethod = props.collectionMethod + this.poNumber = props.poNumber ?? '' + this.termsAndConditions = props.termsAndConditions ?? '' this.pendingChange = props.pendingChange ?? null + this.service = props.service ?? 'recurly' + this.state = props.state ?? 'active' + this.trialPeriodStart = props.trialPeriodStart ?? null + this.trialPeriodEnd = props.trialPeriodEnd ?? null + this.pausePeriodStart = props.pausePeriodStart ?? null + this.remainingPauseCycles = props.remainingPauseCycles ?? null } /** @@ -87,7 +107,7 @@ class RecurlySubscription { /** * Change this subscription's plan * - * @return {RecurlySubscriptionChangeRequest} + * @return {PaymentProviderSubscriptionChangeRequest} */ getRequestForPlanChange(planCode) { const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode) @@ -105,7 +125,7 @@ class RecurlySubscription { newPlan ) - const changeRequest = new RecurlySubscriptionChangeRequest({ + const changeRequest = new PaymentProviderSubscriptionChangeRequest({ subscription: this, timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now', planCode, @@ -117,7 +137,7 @@ class RecurlySubscription { (!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) || (shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE)) ) { - const addOnUpdate = new RecurlySubscriptionAddOnUpdate({ + const addOnUpdate = new PaymentProviderSubscriptionAddOnUpdate({ code: AI_ADD_ON_CODE, quantity: 1, }) @@ -133,7 +153,7 @@ class RecurlySubscription { * @param {string} code * @param {number} [quantity] * @param {number} [unitPrice] - * @return {RecurlySubscriptionChangeRequest} - the change request to send to + * @return {PaymentProviderSubscriptionChangeRequest} - the change request to send to * Recurly * * @throws {DuplicateAddOnError} if the add-on is already present on the subscription @@ -148,9 +168,9 @@ class RecurlySubscription { const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate()) addOnUpdates.push( - new RecurlySubscriptionAddOnUpdate({ code, quantity, unitPrice }) + new PaymentProviderSubscriptionAddOnUpdate({ code, quantity, unitPrice }) ) - return new RecurlySubscriptionChangeRequest({ + return new PaymentProviderSubscriptionChangeRequest({ subscription: this, timeframe: 'now', addOnUpdates, @@ -162,7 +182,7 @@ class RecurlySubscription { * * @param {string} code * @param {number} quantity - * @return {RecurlySubscriptionChangeRequest} - the change request to send to + * @return {PaymentProviderSubscriptionChangeRequest} - the change request to send to * Recurly * * @throws {AddOnNotPresentError} if the subscription doesn't have the add-on @@ -188,7 +208,7 @@ class RecurlySubscription { return update }) - return new RecurlySubscriptionChangeRequest({ + return new PaymentProviderSubscriptionChangeRequest({ subscription: this, timeframe: 'now', addOnUpdates, @@ -199,7 +219,7 @@ class RecurlySubscription { * Remove an add-on from this subscription * * @param {string} code - * @return {RecurlySubscriptionChangeRequest} + * @return {PaymentProviderSubscriptionChangeRequest} * * @throws {AddOnNotPresentError} if the subscription doesn't have the add-on */ @@ -216,7 +236,7 @@ class RecurlySubscription { const addOnUpdates = this.addOns .filter(addOn => addOn.code !== code) .map(addOn => addOn.toAddOnUpdate()) - return new RecurlySubscriptionChangeRequest({ + return new PaymentProviderSubscriptionChangeRequest({ subscription: this, timeframe: 'term_end', addOnUpdates, @@ -227,19 +247,19 @@ class RecurlySubscription { * Upgrade group plan with the plan code provided * * @param {string} newPlanCode - * @return {RecurlySubscriptionChangeRequest} + * @return {PaymentProviderSubscriptionChangeRequest} */ getRequestForGroupPlanUpgrade(newPlanCode) { // Ensure all the existing add-ons are added to the new plan const addOns = this.addOns.map( addOn => - new RecurlySubscriptionAddOnUpdate({ + new PaymentProviderSubscriptionAddOnUpdate({ code: addOn.code, quantity: addOn.quantity, }) ) - return new RecurlySubscriptionChangeRequest({ + return new PaymentProviderSubscriptionChangeRequest({ subscription: this, timeframe: 'now', addOnUpdates: addOns, @@ -247,6 +267,39 @@ class RecurlySubscription { }) } + /** + * Update the "PO number" and "Terms and conditions" in a subscription + * + * @param {string} poNumber + * @param {string} termsAndConditions + * @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to + * Recurly + */ + getRequestForPoNumberAndTermsAndConditionsUpdate( + poNumber, + termsAndConditions + ) { + return new PaymentProviderSubscriptionUpdateRequest({ + subscription: this, + poNumber, + termsAndConditions, + }) + } + + /** + * Update the "Terms and conditions" in a subscription + * + * @param {string} termsAndConditions + * @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to + * Recurly + */ + getRequestForTermsAndConditionsUpdate(termsAndConditions) { + return new PaymentProviderSubscriptionUpdateRequest({ + subscription: this, + termsAndConditions, + }) + } + /** * Returns whether this subscription is manually collected * @@ -260,7 +313,7 @@ class RecurlySubscription { /** * An add-on attached to a subscription */ -class RecurlySubscriptionAddOn { +class PaymentProviderSubscriptionAddOn { /** * @param {object} props * @param {string} props.code @@ -280,7 +333,7 @@ class RecurlySubscriptionAddOn { * Return an add-on update that doesn't modify the add-on */ toAddOnUpdate() { - return new RecurlySubscriptionAddOnUpdate({ + return new PaymentProviderSubscriptionAddOnUpdate({ code: this.code, quantity: this.quantity, unitPrice: this.unitPrice, @@ -288,17 +341,33 @@ class RecurlySubscriptionAddOn { } } -class RecurlySubscriptionChangeRequest { +class PaymentProviderSubscriptionUpdateRequest { /** * @param {object} props - * @param {RecurlySubscription} props.subscription + * @param {PaymentProviderSubscription} props.subscription + * @param {string} [props.poNumber] + * @param {string} [props.termsAndConditions] + */ + constructor(props) { + this.subscription = props.subscription + this.poNumber = props.poNumber ?? '' + this.termsAndConditions = props.termsAndConditions ?? '' + } +} + +class PaymentProviderSubscriptionChangeRequest { + /** + * @param {object} props + * @param {PaymentProviderSubscription} props.subscription * @param {"now" | "term_end"} props.timeframe * @param {string} [props.planCode] - * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates] + * @param {PaymentProviderSubscriptionAddOnUpdate[]} [props.addOnUpdates] */ constructor(props) { if (props.planCode == null && props.addOnUpdates == null) { - throw new OError('Invalid RecurlySubscriptionChangeRequest', { props }) + throw new OError('Invalid PaymentProviderSubscriptionChangeRequest', { + props, + }) } this.subscription = props.subscription this.timeframe = props.timeframe @@ -307,7 +376,7 @@ class RecurlySubscriptionChangeRequest { } } -class RecurlySubscriptionAddOnUpdate { +class PaymentProviderSubscriptionAddOnUpdate { /** * @param {object} props * @param {string} props.code @@ -321,15 +390,15 @@ class RecurlySubscriptionAddOnUpdate { } } -class RecurlySubscriptionChange { +class PaymentProviderSubscriptionChange { /** * @param {object} props - * @param {RecurlySubscription} props.subscription + * @param {PaymentProviderSubscription} props.subscription * @param {string} props.nextPlanCode * @param {string} props.nextPlanName * @param {number} props.nextPlanPrice - * @param {RecurlySubscriptionAddOn[]} props.nextAddOns - * @param {RecurlyImmediateCharge} [props.immediateCharge] + * @param {PaymentProviderSubscriptionAddOn[]} props.nextAddOns + * @param {PaymentProviderImmediateCharge} [props.immediateCharge] */ constructor(props) { this.subscription = props.subscription @@ -339,7 +408,12 @@ class RecurlySubscriptionChange { this.nextAddOns = props.nextAddOns this.immediateCharge = props.immediateCharge ?? - new RecurlyImmediateCharge({ subtotal: 0, tax: 0, total: 0, discount: 0 }) + new PaymentProviderImmediateCharge({ + subtotal: 0, + tax: 0, + total: 0, + discount: 0, + }) this.subtotal = this.nextPlanPrice for (const addOn of this.nextAddOns) { @@ -378,7 +452,7 @@ class CreditCardPaymentMethod { } } -class RecurlyImmediateCharge { +class PaymentProviderImmediateCharge { /** * @param {object} props * @param {number} props.subtotal @@ -397,7 +471,7 @@ class RecurlyImmediateCharge { /** * An add-on configuration, independent of any subscription */ -class RecurlyAddOn { +class PaymentProviderAddOn { /** * @param {object} props * @param {string} props.code @@ -412,7 +486,7 @@ class RecurlyAddOn { /** * A plan configuration */ -class RecurlyPlan { +class PaymentProviderPlan { /** * @param {object} props * @param {string} props.code @@ -424,6 +498,40 @@ class RecurlyPlan { } } +/** + * A coupon in the payment provider + */ +class PaymentProviderCoupon { + /** + * @param {object} props + * @param {string} props.code + * @param {string} props.name + * @param {string} props.description + */ + constructor(props) { + this.code = props.code + this.name = props.name + this.description = props.description + } +} + +/** + * An account in the payment provider + */ +class PaymentProviderAccount { + /** + * @param {object} props + * @param {string} props.code + * @param {string} props.email + * @param {boolean} props.hasPastDueInvoice + */ + constructor(props) { + this.code = props.code + this.email = props.email + this.hasPastDueInvoice = props.hasPastDueInvoice ?? false + } +} + /** * Returns whether the given plan code is a standalone AI plan * @@ -433,19 +541,39 @@ function isStandaloneAiAddOnPlanCode(planCode) { return STANDALONE_AI_ADD_ON_CODES.includes(planCode) } +/** + * Returns whether subscription change will have have the ai bundle once the change is processed + * + * @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from payment provider + * + * @return {boolean} + */ +function subscriptionChangeIsAiAssistUpgrade(subscriptionChange) { + return Boolean( + isStandaloneAiAddOnPlanCode(subscriptionChange.nextPlanCode) || + subscriptionChange.nextAddOns?.some( + addOn => addOn.code === AI_ADD_ON_CODE + ) + ) +} + module.exports = { AI_ADD_ON_CODE, MEMBERS_LIMIT_ADD_ON_CODE, STANDALONE_AI_ADD_ON_CODES, - RecurlySubscription, - RecurlySubscriptionAddOn, - RecurlySubscriptionChange, - RecurlySubscriptionChangeRequest, - RecurlySubscriptionAddOnUpdate, + PaymentProviderSubscription, + PaymentProviderSubscriptionAddOn, + PaymentProviderSubscriptionChange, + PaymentProviderSubscriptionChangeRequest, + PaymentProviderSubscriptionUpdateRequest, + PaymentProviderSubscriptionAddOnUpdate, PaypalPaymentMethod, CreditCardPaymentMethod, - RecurlyAddOn, - RecurlyPlan, + PaymentProviderAddOn, + PaymentProviderPlan, + PaymentProviderCoupon, + PaymentProviderAccount, isStandaloneAiAddOnPlanCode, - RecurlyImmediateCharge, + subscriptionChangeIsAiAssistUpgrade, + PaymentProviderImmediateCharge, } diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 7bd7d1f3b2..f5f2e5f31f 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -7,22 +7,25 @@ const OError = require('@overleaf/o-error') const { callbackify } = require('util') const UserGetter = require('../User/UserGetter') const { - RecurlySubscription, - RecurlySubscriptionAddOn, - RecurlySubscriptionChange, + PaymentProviderSubscription, + PaymentProviderSubscriptionAddOn, + PaymentProviderSubscriptionChange, PaypalPaymentMethod, CreditCardPaymentMethod, - RecurlyAddOn, - RecurlyPlan, - RecurlyImmediateCharge, -} = require('./RecurlyEntities') + PaymentProviderAddOn, + PaymentProviderPlan, + PaymentProviderCoupon, + PaymentProviderAccount, + PaymentProviderImmediateCharge, +} = require('./PaymentProviderEntities') const { MissingBillingInfoError, SubtotalLimitExceededError, } = require('./Errors') /** - * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities' + * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities' + * @import { PaymentProviderSubscriptionUpdateRequest } from './PaymentProviderEntities' * @import { PaymentMethod } from './types' */ @@ -31,13 +34,21 @@ const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined const client = new recurly.Client(recurlyApiKey) +/** + * Get account for a given user + * + * @param {string} userId + * @return {Promise} + */ async function getAccountForUserId(userId) { try { - return await client.getAccount(`code-${userId}`) + const account = await client.getAccount(`code-${userId}`) + return accountFromApi(account) } catch (err) { if (err instanceof recurly.errors.NotFoundError) { // An expected error, we don't need to handle it, just return nothing logger.debug({ userId }, 'no recurly account found for user') + return null } else { throw err } @@ -62,11 +73,76 @@ async function createAccountForUserId(userId) { return account } +/** + * Get active coupons for a given user + * + * @param {string} userId + * @return {Promise} + */ +async function getActiveCouponsForUserId(userId) { + try { + const redemptions = await client.listActiveCouponRedemptions( + `code-${userId}` + ) + + const coupons = [] + for await (const redemption of redemptions.each()) { + coupons.push(couponFromApi(redemption)) + } + + return coupons + } catch (err) { + // An expected error if no coupons have been redeemed + if (err instanceof recurly.errors.NotFoundError) { + return [] + } else { + throw err + } + } +} + +/** + * Get hosted customer management link + * + * @param {string} userId + * @param {string} pageType + * @return {Promise} + */ +async function getCustomerManagementLink(userId, pageType) { + try { + const account = await client.getAccount(`code-${userId}`) + const recurlySubdomain = Settings.apis.recurly.subdomain + const hostedLoginToken = account.hostedLoginToken + if (!hostedLoginToken) { + throw new OError('recurly account does not have hosted login token') + } + let path = '' + if (pageType === 'billing-details') { + path = 'billing_info/edit?ht=' + } + return [ + 'https://', + recurlySubdomain, + '.recurly.com/account/', + path, + hostedLoginToken, + ].join('') + } catch (err) { + if (err instanceof recurly.errors.NotFoundError) { + // An expected error, we don't need to handle it, just return nothing + logger.debug({ userId }, 'no recurly account found for user') + return null + } else { + throw err + } + } +} + /** * Get a subscription from Recurly * * @param {string} subscriptionId - * @return {Promise} + * @return {Promise} */ async function getSubscription(subscriptionId) { const subscription = await client.getSubscription(`uuid-${subscriptionId}`) @@ -80,7 +156,7 @@ async function getSubscription(subscriptionId) { * error if the user has more than one subscription. * * @param {string} userId - * @return {Promise} + * @return {Promise} */ async function getSubscriptionForUser(userId) { try { @@ -113,9 +189,31 @@ async function getSubscriptionForUser(userId) { } /** - * Request a susbcription change from Recurly + * Request a subscription update from Recurly * - * @param {RecurlySubscriptionChangeRequest} changeRequest + * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest + */ +async function updateSubscriptionDetails(updateRequest) { + const body = subscriptionUpdateRequestToApi(updateRequest) + + const updatedSubscription = await client.updateSubscription( + `uuid-${updateRequest.subscription.id}`, + body + ) + + logger.debug( + { + subscriptionId: updateRequest.subscription.id, + updateId: updatedSubscription.id, + }, + 'updated subscription' + ) +} + +/** + * Request a subscription change from Recurly + * + * @param {PaymentProviderSubscriptionChangeRequest} changeRequest */ async function applySubscriptionChangeRequest(changeRequest) { const body = subscriptionChangeRequestToApi(changeRequest) @@ -155,8 +253,8 @@ async function applySubscriptionChangeRequest(changeRequest) { /** * Preview a subscription change * - * @param {RecurlySubscriptionChangeRequest} changeRequest - * @return {Promise} + * @param {PaymentProviderSubscriptionChangeRequest} changeRequest + * @return {Promise} */ async function previewSubscriptionChange(changeRequest) { const body = subscriptionChangeRequestToApi(changeRequest) @@ -265,7 +363,7 @@ async function getPaymentMethod(userId) { * * @param {string} planCode * @param {string} addOnCode - * @return {Promise} + * @return {Promise} */ async function getAddOn(planCode, addOnCode) { const addOn = await client.getPlanAddOn( @@ -279,23 +377,80 @@ async function getAddOn(planCode, addOnCode) { * Get the configuration for a given plan * * @param {string} planCode - * @return {Promise} + * @return {Promise} */ async function getPlan(planCode) { const plan = await client.getPlan(`code-${planCode}`) return planFromApi(plan) } +/** + * Get the country code for given user + * + * @param {string} userId + * @return {Promise} + */ +async function getCountryCode(userId) { + const account = await client.getAccount(`code-${userId}`) + const countryCode = account.address?.country + + if (!countryCode) { + throw new OError('Country code not found', { + userId, + }) + } + + return countryCode +} + function subscriptionIsCanceledOrExpired(subscription) { const state = subscription?.recurlyStatus?.state return state === 'canceled' || state === 'expired' } /** - * Build a RecurlySubscription from Recurly API data + * Build a PaymentProviderAccount from Recurly API data + * + * @param {recurly.Account} apiAccount + * @return {PaymentProviderAccount} + */ +function accountFromApi(apiAccount) { + if (apiAccount.code == null || apiAccount.email == null) { + throw new OError('Invalid Recurly account', { + account: apiAccount, + }) + } + return new PaymentProviderAccount({ + code: apiAccount.code, + email: apiAccount.email, + hasPastDueInvoice: apiAccount.hasPastDueInvoice ?? false, + }) +} + +/** + * Build a PaymentProviderCoupon from Recurly API data + * + * @param {recurly.CouponRedemption} apiRedemption + * @return {PaymentProviderCoupon} + */ +function couponFromApi(apiRedemption) { + if (apiRedemption.coupon == null || apiRedemption.coupon.code == null) { + throw new OError('Invalid Recurly coupon', { + coupon: apiRedemption, + }) + } + return new PaymentProviderCoupon({ + code: apiRedemption.coupon.code, + name: apiRedemption.coupon.name ?? '', + description: apiRedemption.coupon.hostedPageDescription ?? '', + }) +} + +/** + * Build a PaymentProviderSubscription from Recurly API data * * @param {recurly.Subscription} apiSubscription - * @return {RecurlySubscription} + * @return {PaymentProviderSubscription} */ function subscriptionFromApi(apiSubscription) { if ( @@ -311,14 +466,17 @@ function subscriptionFromApi(apiSubscription) { apiSubscription.currency == null || apiSubscription.currentPeriodStartedAt == null || apiSubscription.currentPeriodEndsAt == null || - apiSubscription.collectionMethod == null + apiSubscription.collectionMethod == null || + // The values below could be null initially if the subscription has never updated + !('poNumber' in apiSubscription) || + !('termsAndConditions' in apiSubscription) ) { throw new OError('Invalid Recurly subscription', { subscription: apiSubscription, }) } - const subscription = new RecurlySubscription({ + const subscription = new PaymentProviderSubscription({ id: apiSubscription.uuid, userId: apiSubscription.account.code, planCode: apiSubscription.plan.code, @@ -333,6 +491,14 @@ function subscriptionFromApi(apiSubscription) { periodStart: apiSubscription.currentPeriodStartedAt, periodEnd: apiSubscription.currentPeriodEndsAt, collectionMethod: apiSubscription.collectionMethod, + poNumber: apiSubscription.poNumber ?? '', + termsAndConditions: apiSubscription.termsAndConditions ?? '', + service: 'recurly', + state: apiSubscription.state ?? 'active', + trialPeriodStart: apiSubscription.trialStartedAt, + trialPeriodEnd: apiSubscription.trialEndsAt, + pausePeriodStart: apiSubscription.pausedAt, + remainingPauseCycles: apiSubscription.remainingPauseCycles, }) if (apiSubscription.pendingChange != null) { @@ -346,10 +512,10 @@ function subscriptionFromApi(apiSubscription) { } /** - * Build a RecurlySubscriptionAddOn from Recurly API data + * Build a PaymentProviderSubscriptionAddOn from Recurly API data * * @param {recurly.SubscriptionAddOn} addOn - * @return {RecurlySubscriptionAddOn} + * @return {PaymentProviderSubscriptionAddOn} */ function subscriptionAddOnFromApi(addOn) { if ( @@ -361,7 +527,7 @@ function subscriptionAddOnFromApi(addOn) { throw new OError('Invalid Recurly add-on', { addOn }) } - return new RecurlySubscriptionAddOn({ + return new PaymentProviderSubscriptionAddOn({ code: addOn.addOn.code, name: addOn.addOn.name, quantity: addOn.quantity ?? 1, @@ -370,11 +536,11 @@ function subscriptionAddOnFromApi(addOn) { } /** - * Build a RecurlySubscriptionChange from Recurly API data + * Build a PaymentProviderSubscriptionChange from Recurly API data * - * @param {RecurlySubscription} subscription - the current subscription + * @param {PaymentProviderSubscription} subscription - the current subscription * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API - * @return {RecurlySubscriptionChange} + * @return {PaymentProviderSubscriptionChange} */ function subscriptionChangeFromApi(subscription, subscriptionChange) { if ( @@ -391,7 +557,7 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) { subscriptionAddOnFromApi ) - return new RecurlySubscriptionChange({ + return new PaymentProviderSubscriptionChange({ subscription, nextPlanCode: subscriptionChange.plan.code, nextPlanName: subscriptionChange.plan.name, @@ -405,7 +571,7 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) { * Compute immediate charge based on invoice collection * * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API - * @return {RecurlyImmediateCharge} + * @return {PaymentProviderImmediateCharge} */ function computeImmediateCharge(subscriptionChange) { const roundToTwoDecimal = (/** @type {number} */ num) => @@ -425,7 +591,7 @@ function computeImmediateCharge(subscriptionChange) { tax = roundToTwoDecimal(tax + (creditInvoice.tax ?? 0)) discount = roundToTwoDecimal(discount + (creditInvoice.discount ?? 0)) } - return new RecurlyImmediateCharge({ + return new PaymentProviderImmediateCharge({ subtotal, total, tax, @@ -459,41 +625,41 @@ function paymentMethodFromApi(billingInfo) { } /** - * Build a RecurlyAddOn from Recurly API data + * Build a PaymentProviderAddOn from Recurly API data * * @param {recurly.AddOn} addOn - * @return {RecurlyAddOn} + * @return {PaymentProviderAddOn} */ function addOnFromApi(addOn) { if (addOn.code == null || addOn.name == null) { throw new OError('Invalid Recurly add-on', { addOn }) } - return new RecurlyAddOn({ + return new PaymentProviderAddOn({ code: addOn.code, name: addOn.name, }) } /** - * Build a RecurlyPlan from Recurly API data + * Build a PaymentProviderPlan from Recurly API data * * @param {recurly.Plan} plan - * @return {RecurlyPlan} + * @return {PaymentProviderPlan} */ function planFromApi(plan) { if (plan.code == null || plan.name == null) { throw new OError('Invalid Recurly add-on', { plan }) } - return new RecurlyPlan({ + return new PaymentProviderPlan({ code: plan.code, name: plan.name, }) } /** - * Build an API request from a RecurlySubscriptionChangeRequest + * Build an API request from a PaymentProviderSubscriptionChangeRequest * - * @param {RecurlySubscriptionChangeRequest} changeRequest + * @param {PaymentProviderSubscriptionChangeRequest} changeRequest * @return {recurly.SubscriptionChangeCreate} */ function subscriptionChangeRequestToApi(changeRequest) { @@ -520,14 +686,32 @@ function subscriptionChangeRequestToApi(changeRequest) { return requestBody } +/** + * Build an API request from a PaymentProviderSubscriptionUpdateRequest + * + * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest + */ +function subscriptionUpdateRequestToApi(updateRequest) { + const requestBody = {} + if (updateRequest.poNumber) { + requestBody.poNumber = updateRequest.poNumber + } + if (updateRequest.termsAndConditions) { + requestBody.termsAndConditions = updateRequest.termsAndConditions + } + return requestBody +} + module.exports = { errors: recurly.errors, getAccountForUserId: callbackify(getAccountForUserId), createAccountForUserId: callbackify(createAccountForUserId), + getActiveCouponsForUserId: callbackify(getActiveCouponsForUserId), getSubscription: callbackify(getSubscription), getSubscriptionForUser: callbackify(getSubscriptionForUser), previewSubscriptionChange: callbackify(previewSubscriptionChange), + updateSubscriptionDetails: callbackify(updateSubscriptionDetails), applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest), removeSubscriptionChange: callbackify(removeSubscriptionChange), removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid), @@ -536,6 +720,7 @@ module.exports = { getPaymentMethod: callbackify(getPaymentMethod), getAddOn: callbackify(getAddOn), getPlan: callbackify(getPlan), + getCountryCode: callbackify(getCountryCode), subscriptionIsCanceledOrExpired, pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid), resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid), @@ -545,7 +730,10 @@ module.exports = { getSubscriptionForUser, getAccountForUserId, createAccountForUserId, + getActiveCouponsForUserId, + getCustomerManagementLink, previewSubscriptionChange, + updateSubscriptionDetails, applySubscriptionChangeRequest, removeSubscriptionChange, removeSubscriptionChangeByUuid, @@ -556,5 +744,6 @@ module.exports = { getPaymentMethod, getAddOn, getPlan, + getCountryCode, }, } diff --git a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js index 906e1258ea..d97d57ecba 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js +++ b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js @@ -1,7 +1,7 @@ const SplitTestHandler = require('../SplitTests/SplitTestHandler') const AnalyticsManager = require('../Analytics/AnalyticsManager') const SubscriptionEmailHandler = require('./SubscriptionEmailHandler') -const { AI_ADD_ON_CODE } = require('./RecurlyEntities') +const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities') const { ObjectId } = require('mongodb-legacy') const INVOICE_SUBSCRIPTION_LIMIT = 10 diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 053c983502..2227597737 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -547,27 +547,6 @@ const promises = { updateAccountEmailAddress, - async getAccountActiveCoupons(accountId) { - const { body } = await RecurlyWrapper.promises.apiRequest({ - url: `accounts/${accountId}/redemptions`, - }) - - const redemptions = await RecurlyWrapper.promises._parseRedemptionsXml(body) - - const activeRedemptions = redemptions.filter( - redemption => redemption.state === 'active' - ) - const couponCodes = activeRedemptions.map( - redemption => redemption.coupon_code - ) - - return await Promise.all( - couponCodes.map(couponCode => - RecurlyWrapper.promises.getCoupon(couponCode) - ) - ) - }, - async getCoupon(couponCode) { const opts = { url: `coupons/${couponCode}` } const { body } = await RecurlyWrapper.promises.apiRequest(opts) @@ -904,7 +883,6 @@ const RecurlyWrapper = { _buildXml, _parseXml: callbackify(promises._parseXml), createFixedAmountCoupon: callbackify(promises.createFixedAmountCoupon), - getAccountActiveCoupons: callbackify(promises.getAccountActiveCoupons), getBillingInfo: callbackify(promises.getBillingInfo), getPaginatedEndpoint: callbackify(promises.getPaginatedEndpoint), getSubscription: callbackify(promises.getSubscription), diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index cb8a293fb4..885784d10d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -22,13 +22,14 @@ const Modules = require('../../infrastructure/Modules') const async = require('async') const HttpErrorHandler = require('../Errors/HttpErrorHandler') const RecurlyClient = require('./RecurlyClient') -const { AI_ADD_ON_CODE } = require('./RecurlyEntities') +const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities') const PlansLocator = require('./PlansLocator') +const PaymentProviderEntities = require('./PaymentProviderEntities') /** * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview' * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview' - * @import { RecurlySubscriptionChange } from './RecurlyEntities' + * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities' * @import { PaymentMethod } from './types' */ @@ -45,7 +46,6 @@ function formatGroupPlansDataForDash() { async function userSubscriptionPage(req, res) { const user = SessionManager.getSessionUser(req.session) - await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription') const groupPricingDiscount = await SplitTestHandler.promises.getAssignment( @@ -154,6 +154,9 @@ async function userSubscriptionPage(req, res) { ) } + const hasAiAssistViaWritefull = + await FeaturesUpdater.promises.hasFeaturesViaWritefull(user._id) + const data = { title: 'your_subscription', plans: plansData?.plans, @@ -176,6 +179,7 @@ async function userSubscriptionPage(req, res) { groupSettingsEnabledFor, isManagedAccount: !!req.managedBy, userRestrictions: Array.from(req.userRestrictions || []), + hasAiAssistViaWritefull, } res.render('subscriptions/dashboard-react', data) } @@ -310,6 +314,7 @@ function cancelV1Subscription(req, res, next) { async function previewAddonPurchase(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) const addOnCode = req.params.addOnCode + const purchaseReferrer = req.query.purchaseReferrer if (addOnCode !== AI_ADD_ON_CODE) { return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`) @@ -321,13 +326,21 @@ async function previewAddonPurchase(req, res) { try { subscriptionChange = await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode) + + const hasAiAssistViaWritefull = + await FeaturesUpdater.promises.hasFeaturesViaWritefull(userId) + const isAiUpgrade = + PaymentProviderEntities.subscriptionChangeIsAiAssistUpgrade( + subscriptionChange + ) + if (hasAiAssistViaWritefull && isAiUpgrade) { + return res.redirect( + '/user/subscription?redirect-reason=writefull-entitled' + ) + } } catch (err) { if (err instanceof DuplicateAddOnError) { - return HttpErrorHandler.badRequest( - req, - res, - `Subscription already has add-on "${addOnCode}"` - ) + return res.redirect('/user/subscription?redirect-reason=double-buy') } throw err } @@ -351,7 +364,10 @@ async function previewAddonPurchase(req, res) { paymentMethod ) - res.render('subscriptions/preview-change', { changePreview }) + res.render('subscriptions/preview-change', { + changePreview, + purchaseReferrer, + }) } async function purchaseAddon(req, res, next) { @@ -599,18 +615,6 @@ async function refreshUserFeatures(req, res) { res.sendStatus(200) } -async function redirectToHostedPage(req, res) { - const userId = SessionManager.getLoggedInUserId(req.session) - const { pageType } = req.params - const url = - await SubscriptionViewModelBuilder.promises.getRedirectToHostedPage( - userId, - pageType - ) - logger.warn({ userId, pageType }, 'redirecting to recurly hosted page') - res.redirect(url) -} - async function getRecommendedCurrency(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) let ip = req.ip @@ -718,8 +722,8 @@ function getPlanNameForDisplay(planName, planCode) { * Build a subscription change preview for display purposes * * @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend - * @param {RecurlySubscriptionChange} subscriptionChange The subscription change object coming from Recurly - * @param {PaymentMethod} paymentMethod The payment method associated to the user + * @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from Recurly + * @param {PaymentMethod} [paymentMethod] The payment method associated to the user * @return {SubscriptionChangePreview} */ function makeChangePreview( @@ -735,7 +739,7 @@ function makeChangePreview( change: subscriptionChangeDescription, currency: subscription.currency, immediateCharge: { ...subscriptionChange.immediateCharge }, - paymentMethod: paymentMethod.toString(), + paymentMethod: paymentMethod?.toString(), nextPlan: { annual: nextPlan.annual ?? false, }, @@ -781,7 +785,6 @@ module.exports = { extendTrial: expressify(extendTrial), recurlyNotificationParser, refreshUserFeatures: expressify(refreshUserFeatures), - redirectToHostedPage: expressify(redirectToHostedPage), previewAddonPurchase: expressify(previewAddonPurchase), purchaseAddon, removeAddon, diff --git a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js index 8bdfc2a060..1953f337fa 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js +++ b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js @@ -1,21 +1,4 @@ const dateformat = require('dateformat') -const { formatCurrency } = require('../../util/currency') - -/** - * @param {number} priceInCents - price in the smallest currency unit (e.g. dollar cents, CLP units, ...) - * @param {CurrencyCode?} currency - currency code (default to USD) - * @param {string} [locale] - locale string - * @returns {string} - formatted price - */ -function formatPriceLocalized(priceInCents, currency = 'USD', locale) { - const isNoCentsCurrency = ['CLP', 'JPY', 'KRW', 'VND'].includes(currency) - - const priceInCurrencyUnit = isNoCentsCurrency - ? priceInCents - : priceInCents / 100 - - return formatCurrency(priceInCurrencyUnit, currency, locale) -} function formatDateTime(date) { if (!date) { @@ -32,7 +15,6 @@ function formatDate(date) { } module.exports = { - formatPriceLocalized, formatDateTime, formatDate, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 14d73f91de..6ce552ec75 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -8,6 +8,7 @@ import SessionManager from '../Authentication/SessionManager.js' import UserAuditLogHandler from '../User/UserAuditLogHandler.js' import { expressify } from '@overleaf/promise-utils' import Modules from '../../infrastructure/Modules.js' +import SplitTestHandler from '../SplitTests/SplitTestHandler.js' import UserGetter from '../User/UserGetter.js' import { Subscription } from '../../models/Subscription.js' import { isProfessionalGroupPlan } from './PlansHelper.mjs' @@ -135,23 +136,39 @@ async function addSeatsToGroupSubscription(req, res) { userId ) await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan) - await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual( - recurlySubscription - ) await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges( recurlySubscription ) - // Check if the user has missing billing details - await RecurlyClient.promises.getPaymentMethod(userId) await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive( subscription ) + const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } = + await SplitTestHandler.promises.getAssignment( + req, + res, + 'flexible-group-licensing-for-manually-billed-subscriptions' + ) + + if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') { + await SubscriptionGroupHandler.promises.checkBillingInfoExistence( + recurlySubscription, + userId + ) + } else { + await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual( + recurlySubscription + ) + // Check if the user has missing billing details + await RecurlyClient.promises.getPaymentMethod(userId) + } + res.render('subscriptions/add-seats', { subscriptionId: subscription._id, groupName: subscription.teamName, totalLicenses: subscription.membersLimit, isProfessional: isProfessionalGroupPlan(subscription), + isCollectionMethodManual: recurlySubscription.isCollectionMethodManual, }) } catch (error) { if (error instanceof MissingBillingInfoError) { @@ -231,7 +248,8 @@ async function createAddSeatsSubscriptionChange(req, res) { const create = await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange( userId, - req.body.adding + req.body.adding, + req.body.poNumber ) res.json(create) @@ -264,11 +282,27 @@ async function createAddSeatsSubscriptionChange(req, res) { async function submitForm(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) const userEmail = await UserGetter.promises.getUserEmail(userId) - const { adding } = req.body + const { adding, poNumber } = req.body + + const { recurlySubscription } = + await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails( + userId + ) + + if (recurlySubscription.isCollectionMethodManual) { + await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms( + userId, + recurlySubscription, + poNumber + ) + } const messageLines = [`\n**Overleaf Sales Contact Form:**`] messageLines.push('**Subject:** Self-Serve Group User Increase Request') messageLines.push(`**Estimated Number of Users:** ${adding}`) + if (poNumber) { + messageLines.push(`**PO Number:** ${poNumber}`) + } messageLines.push( `**Message:** This email has been generated on behalf of user with email **${userEmail}** ` + 'to request an increase in the total number of users for their subscription.' diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index 05befd6ca5..b92ce807f6 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -1,18 +1,25 @@ const { callbackify } = require('util') +const _ = require('lodash') +const OError = require('@overleaf/o-error') const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionController = require('./SubscriptionController') const { Subscription } = require('../../models/Subscription') +const { User } = require('../../models/User') const RecurlyClient = require('./RecurlyClient') const PlansLocator = require('./PlansLocator') const SubscriptionHandler = require('./SubscriptionHandler') +const TeamInvitesHandler = require('./TeamInvitesHandler') const GroupPlansData = require('./GroupPlansData') -const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./RecurlyEntities') +const Modules = require('../../infrastructure/Modules') +const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities') const { ManuallyCollectedError, PendingChangeError, InactiveError, } = require('./Errors') +const EmailHelper = require('../Helpers/EmailHelper') +const { InvalidEmailError } = require('../Errors/Errors') async function removeUserFromGroup(subscriptionId, userIdToRemove) { await SubscriptionUpdater.promises.removeUserFromGroup( @@ -122,13 +129,21 @@ async function getUsersGroupSubscriptionDetails(userId) { } } +async function checkBillingInfoExistence(recurlySubscription, userId) { + // Verify the billing info only if the collection method is not manual (e.g. automatic) + if (!recurlySubscription.isCollectionMethodManual) { + // Check if the user has missing billing details + await RecurlyClient.promises.getPaymentMethod(userId) + } +} + async function _addSeatsSubscriptionChange(userId, adding) { const { subscription, recurlySubscription, plan } = await getUsersGroupSubscriptionDetails(userId) await ensureFlexibleLicensingEnabled(plan) await ensureSubscriptionIsActive(subscription) - await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription) await ensureSubscriptionHasNoPendingChanges(recurlySubscription) + await checkBillingInfoExistence(recurlySubscription, userId) const currentAddonQuantity = recurlySubscription.addOns.find( @@ -202,7 +217,6 @@ function _shouldUseLegacyPricing( async function previewAddSeatsSubscriptionChange(userId, adding) { const { changeRequest, currentAddonQuantity } = await _addSeatsSubscriptionChange(userId, adding) - const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId) const subscriptionChange = await RecurlyClient.promises.previewSubscriptionChange(changeRequest) const subscriptionChangePreview = @@ -217,16 +231,20 @@ async function previewAddSeatsSubscriptionChange(userId, adding) { prevQuantity: currentAddonQuantity, }, }, - subscriptionChange, - paymentMethod + subscriptionChange ) return subscriptionChangePreview } -async function createAddSeatsSubscriptionChange(userId, adding) { +async function createAddSeatsSubscriptionChange(userId, adding, poNumber) { const { changeRequest, recurlySubscription } = await _addSeatsSubscriptionChange(userId, adding) + + if (recurlySubscription.isCollectionMethodManual) { + await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber) + } + await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) await SubscriptionHandler.promises.syncSubscription( { uuid: recurlySubscription.id }, @@ -236,6 +254,29 @@ async function createAddSeatsSubscriptionChange(userId, adding) { return { adding } } +async function updateSubscriptionPaymentTerms( + userId, + recurlySubscription, + poNumber +) { + const countryCode = await RecurlyClient.promises.getCountryCode(userId) + const [termsAndConditions] = await Modules.promises.hooks.fire( + 'generateTermsAndConditions', + { countryCode, poNumber } + ) + + const updateRequest = poNumber + ? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate( + poNumber, + termsAndConditions + ) + : recurlySubscription.getRequestForTermsAndConditionsUpdate( + termsAndConditions + ) + + await RecurlyClient.promises.updateSubscriptionDetails(updateRequest) +} + async function _getUpgradeTargetPlanCodeMaybeThrow(subscription) { if ( subscription.planCode.includes('professional') || @@ -293,6 +334,125 @@ async function upgradeGroupPlan(ownerId) { ) } +async function updateGroupMembersBulk( + inviterId, + subscriptionId, + emailList, + options = {} +) { + const { removeMembersNotIncluded, commit } = options + + // remove duplications and empty values + emailList = _.uniq(_.compact(emailList)) + + const invalidEmails = emailList.filter( + email => !EmailHelper.parseEmail(email) + ) + + if (invalidEmails.length > 0) { + throw new InvalidEmailError('email not valid', { + invalidEmails, + }) + } + + const subscription = await Subscription.findOne({ + _id: subscriptionId, + }).exec() + + const existingUserData = await User.find( + { + _id: { $in: subscription.member_ids }, + }, + { _id: 1, email: 1, 'emails.email': 1 } + ).exec() + + const existingUsers = existingUserData.map(user => ({ + _id: user._id, + emails: user.emails?.map(user => user.email), + })) + + const currentMemberEmails = _.flatten( + existingUsers + .filter(userData => userData.emails?.length > 0) + .map(user => user.emails) + ) + + const currentInvites = + subscription.teamInvites?.map(invite => invite.email) || [] + if (subscription.invited_emails?.length > 0) { + currentInvites.push(...subscription.invited_emails) + } + + const invitesToSend = _.difference( + emailList, + currentMemberEmails.concat(currentInvites) + ) + + let membersToRemove + let invitesToRevoke + let newTotalCount + + if (!removeMembersNotIncluded) { + membersToRemove = [] + invitesToRevoke = [] + newTotalCount = + existingUsers.length + currentInvites.length + invitesToSend.length + } else { + membersToRemove = [] + for (const existingUser of existingUsers) { + if (_.intersection(existingUser.emails, emailList).length === 0) { + membersToRemove.push(existingUser._id) + } + } + const invitesToMaintain = _.intersection(emailList, currentInvites) + invitesToRevoke = _.difference(currentInvites, invitesToMaintain) + newTotalCount = + existingUsers.length - + membersToRemove.length + + invitesToMaintain.length + + invitesToSend.length + } + + const result = { + emailsToSendInvite: invitesToSend, + emailsToRevokeInvite: invitesToRevoke, + membersToRemove, + currentMemberCount: existingUsers.length, + newTotalCount, + membersLimit: subscription.membersLimit, + } + + if (commit) { + if (newTotalCount > subscription.membersLimit) { + const { currentMemberCount, newTotalCount, membersLimit } = result + throw new OError('limit reached', { + currentMemberCount, + newTotalCount, + membersLimit, + }) + } + for (const email of invitesToSend) { + await TeamInvitesHandler.promises.createInvite( + inviterId, + subscription, + email + ) + } + for (const email of invitesToRevoke) { + await TeamInvitesHandler.promises.revokeInvite( + inviterId, + subscription, + email + ) + } + for (const user of membersToRemove) { + await removeUserFromGroup(subscription._id, user._id) + } + } + + return result +} + module.exports = { removeUserFromGroup: callbackify(removeUserFromGroup), replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups), @@ -308,6 +468,8 @@ module.exports = { isUserPartOfGroup: callbackify(isUserPartOfGroup), getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview), upgradeGroupPlan: callbackify(upgradeGroupPlan), + checkBillingInfoExistence: callbackify(checkBillingInfoExistence), + updateGroupMembersBulk: callbackify(updateGroupMembersBulk), promises: { removeUserFromGroup, replaceUserReferencesInGroups, @@ -320,7 +482,10 @@ module.exports = { getUsersGroupSubscriptionDetails, previewAddSeatsSubscriptionChange, createAddSeatsSubscriptionChange, + updateSubscriptionPaymentTerms, getGroupPlanUpgradePreview, upgradeGroupPlan, + checkBillingInfoExistence, + updateGroupMembersBulk, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 4988bb5983..9cff487aec 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -12,9 +12,10 @@ const EmailHandler = require('../Email/EmailHandler') const { callbackify } = require('@overleaf/promise-utils') const UserUpdater = require('../User/UserUpdater') const { NotFoundError } = require('../Errors/Errors') +const Modules = require('../../infrastructure/Modules') /** - * @import { RecurlySubscription, RecurlySubscriptionChange } from './RecurlyEntities' + * @import { PaymentProviderSubscription, PaymentProviderSubscriptionChange } from './PaymentProviderEntities' */ async function validateNoSubscriptionInRecurly(userId) { @@ -69,7 +70,7 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) { * * @param {string} userId * @param {string} planCode - * @return {Promise} + * @return {Promise} */ async function previewSubscriptionChange(userId, planCode) { const subscription = await getSubscriptionForUser(userId) @@ -149,10 +150,7 @@ async function cancelSubscription(user) { const { hasSubscription, subscription } = await LimitationsManager.promises.userHasSubscription(user) if (hasSubscription && subscription != null) { - await RecurlyClient.promises.cancelSubscriptionByUuid( - subscription.recurlySubscription_id - ) - await _updateSubscriptionFromRecurly(subscription) + await Modules.promises.hooks.fire('cancelPaidSubscription', subscription) const emailOpts = { to: user.email, first_name: user.first_name, @@ -180,10 +178,10 @@ async function reactivateSubscription(user) { const { hasSubscription, subscription } = await LimitationsManager.promises.userHasSubscription(user) if (hasSubscription && subscription != null) { - await RecurlyClient.promises.reactivateSubscriptionByUuid( - subscription.recurlySubscription_id + await Modules.promises.hooks.fire( + 'reactivatePaidSubscription', + subscription ) - await _updateSubscriptionFromRecurly(subscription) EmailHandler.sendEmail( 'reactivatedSubscription', { to: user.email }, @@ -267,23 +265,12 @@ async function extendTrial(subscription, daysToExend) { ) } -async function _updateSubscriptionFromRecurly(subscription) { - const recurlySubscription = await RecurlyWrapper.promises.getSubscription( - subscription.recurlySubscription_id, - {} - ) - await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - recurlySubscription, - subscription - ) -} - /** * Preview the effect of purchasing an add-on * * @param {string} userId * @param {string} addOnCode - * @return {Promise} + * @return {Promise} */ async function previewAddonPurchase(userId, addOnCode) { const subscription = await getSubscriptionForUser(userId) @@ -353,7 +340,7 @@ async function removeAddon(userId, addOnCode) { * Throws a NotFoundError if the subscription can't be found * * @param {string} userId - * @return {Promise} + * @return {Promise} */ async function getSubscriptionForUser(userId) { const subscription = diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index a980399c29..ac0fa5918a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.js @@ -5,7 +5,7 @@ const logger = require('@overleaf/logger') const { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode, -} = require('./RecurlyEntities') +} = require('./PaymentProviderEntities') require('./GroupPlansData') // make sure dynamic group plans are loaded const SubscriptionLocator = { diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 0bb30b578e..54523b0004 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -23,6 +23,7 @@ const MAX_NUMBER_OF_USERS = 20 const addSeatsValidateSchema = { body: Joi.object({ adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(), + poNumber: Joi.string(), }), } @@ -54,13 +55,6 @@ export default { SubscriptionController.canceledSubscription ) - webRouter.get( - '/user/subscription/recurly/:pageType', - AuthenticationController.requireLogin(), - RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), - SubscriptionController.redirectToHostedPage - ) - webRouter.delete( '/subscription/group/user', AuthenticationController.requireLogin(), @@ -97,6 +91,7 @@ export default { validate({ body: Joi.object({ adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(), + poNumber: Joi.string(), }), }), RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index 2e43454da9..482d81ff41 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js @@ -13,6 +13,11 @@ const UserAuditLogHandler = require('../User/UserAuditLogHandler') const AccountMappingHelper = require('../Analytics/AccountMappingHelper') const { SSOConfig } = require('../../models/SSOConfig') +/** + * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription + * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider + */ + /** * Change the admin of the given subscription. * @@ -51,7 +56,7 @@ async function syncSubscription( let subscription = await SubscriptionLocator.promises.getUsersSubscription(adminUserId) if (subscription == null) { - subscription = await _createNewSubscription(adminUserId) + subscription = await createNewSubscription(adminUserId) } await updateSubscriptionFromRecurly( recurlySubscription, @@ -160,7 +165,7 @@ async function deleteSubscription(subscription, deleterData) { await Subscription.deleteOne({ _id: subscription._id }).exec() // 4. refresh users features - await _scheduleRefreshFeatures(subscription) + await scheduleRefreshFeatures(subscription) } async function restoreSubscription(subscriptionId) { @@ -201,7 +206,11 @@ async function refreshUsersFeatures(subscription) { } } -async function _scheduleRefreshFeatures(subscription) { +/** + * + * @param {Subscription} subscription + */ +async function scheduleRefreshFeatures(subscription) { const userIds = [subscription.admin_id].concat(subscription.member_ids || []) for (const userId of userIds) { await FeaturesUpdater.promises.scheduleRefreshFeatures( @@ -226,7 +235,13 @@ async function createDeletedSubscription(subscription, deleterData) { await DeletedSubscription.findOneAndUpdate(filter, data, options).exec() } -async function _createNewSubscription(adminUserId) { +/** + * Creates a new subscription for the given admin user. + * + * @param {string} adminUserId + * @returns {Promise} + */ +async function createNewSubscription(adminUserId) { const subscription = new Subscription({ admin_id: adminUserId, manager_ids: [adminUserId], @@ -242,7 +257,7 @@ async function _deleteAndReplaceSubscriptionFromRecurly( ) { const adminUserId = subscription.admin_id await deleteSubscription(subscription, requesterData) - const newSubscription = await _createNewSubscription(adminUserId) + const newSubscription = await createNewSubscription(adminUserId) await updateSubscriptionFromRecurly( recurlySubscription, newSubscription, @@ -356,7 +371,7 @@ async function updateSubscriptionFromRecurly( AnalyticsManager.registerAccountMapping(accountMapping) } - await _scheduleRefreshFeatures(subscription) + await scheduleRefreshFeatures(subscription) } async function _sendUserGroupPlanCodeUserProperty(userId) { @@ -428,6 +443,7 @@ async function _sendSubscriptionEventForAllMembers(subscriptionId, event) { module.exports = { updateAdmin: callbackify(updateAdmin), syncSubscription: callbackify(syncSubscription), + createNewSubscription: callbackify(createNewSubscription), deleteSubscription: callbackify(deleteSubscription), createDeletedSubscription: callbackify(createDeletedSubscription), addUserToGroup: callbackify(addUserToGroup), @@ -437,9 +453,11 @@ module.exports = { deleteWithV1Id: callbackify(deleteWithV1Id), restoreSubscription: callbackify(restoreSubscription), updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly), + scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures), promises: { updateAdmin, syncSubscription, + createNewSubscription, addUserToGroup, refreshUsersFeatures, removeUserFromGroup, @@ -449,5 +467,6 @@ module.exports = { deleteWithV1Id, restoreSubscription, updateSubscriptionFromRecurly, + scheduleRefreshFeatures, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index aa3d577991..129463dcf0 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -5,7 +5,7 @@ const PlansLocator = require('./PlansLocator') const { isStandaloneAiAddOnPlanCode, MEMBERS_LIMIT_ADD_ON_CODE, -} = require('./RecurlyEntities') +} = require('./PaymentProviderEntities') const SubscriptionFormatters = require('./SubscriptionFormatters') const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionUpdater = require('./SubscriptionUpdater') @@ -17,19 +17,17 @@ const _ = require('lodash') const async = require('async') const SubscriptionHelper = require('./SubscriptionHelper') const { callbackify } = require('@overleaf/promise-utils') -const { - InvalidError, - NotFoundError, - V1ConnectionError, -} = require('../Errors/Errors') +const { V1ConnectionError } = require('../Errors/Errors') const FeaturesHelper = require('./FeaturesHelper') +const { formatCurrency } = require('../../util/currency') +const Modules = require('../../infrastructure/Modules') /** * @import { Subscription } from "../../../../types/project/dashboard/subscription" */ function buildHostedLink(type) { - return `/user/subscription/recurly/${type}` + return `/user/subscription/payment/${type}` } // Downgrade from Mongoose object, so we can add custom attributes to object @@ -39,39 +37,6 @@ function serializeMongooseObject(object) { : object } -async function getRedirectToHostedPage(userId, pageType) { - if (!['billing-details', 'account-management'].includes(pageType)) { - throw new InvalidError('unexpected page type') - } - const personalSubscription = - await SubscriptionLocator.promises.getUsersSubscription(userId) - const recurlySubscriptionId = personalSubscription?.recurlySubscription_id - if (!recurlySubscriptionId) { - throw new NotFoundError('not a recurly subscription') - } - const recurlySubscription = await RecurlyWrapper.promises.getSubscription( - recurlySubscriptionId, - { includeAccount: true } - ) - - const recurlySubdomain = Settings.apis.recurly.subdomain - const hostedLoginToken = recurlySubscription.account.hosted_login_token - if (!hostedLoginToken) { - throw new Error('recurly account does not have hosted login token') - } - let path = '' - if (pageType === 'billing-details') { - path = 'billing_info/edit?ht=' - } - return [ - 'https://', - recurlySubdomain, - '.recurly.com/account/', - path, - hostedLoginToken, - ].join('') -} - async function buildUsersSubscriptionViewModel(user, locale = 'en') { let { personalSubscription, @@ -80,38 +45,16 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { currentInstitutionsWithLicence, managedInstitutions, managedPublishers, - recurlySubscription, - recurlyCoupons, + fetchedPaymentRecord, plan, } = await async.auto({ personalSubscription(cb) { SubscriptionLocator.getUsersSubscription(user, cb) }, - recurlySubscription: [ + fetchedPaymentRecord: [ 'personalSubscription', ({ personalSubscription }, cb) => { - if ( - personalSubscription == null || - personalSubscription.recurlySubscription_id == null || - personalSubscription.recurlySubscription_id === '' - ) { - return cb(null, null) - } - RecurlyWrapper.getSubscription( - personalSubscription.recurlySubscription_id, - { includeAccount: true }, - cb - ) - }, - ], - recurlyCoupons: [ - 'recurlySubscription', - ({ recurlySubscription }, cb) => { - if (!recurlySubscription) { - return cb(null, null) - } - const accountId = recurlySubscription.account.account_code - RecurlyWrapper.getAccountActiveCoupons(accountId, cb) + Modules.hooks.fire('getPaymentFromRecord', personalSubscription, cb) }, ], plan: [ @@ -158,6 +101,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { }, }) + const paymentRecord = fetchedPaymentRecord && fetchedPaymentRecord[0] + if (memberGroupSubscriptions == null) { memberGroupSubscriptions = [] } else { @@ -216,9 +161,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { if (managedInstitutions == null) { managedInstitutions = [] } - if (recurlyCoupons == null) { - recurlyCoupons = [] - } personalSubscription = serializeMongooseObject(personalSubscription) @@ -235,14 +177,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { personalSubscription.plan = plan } - // Subscription DB object contains a recurly property, used to cache trial info - // on the project-list. However, this can cause the wrong template to render, - // if we do not have any subscription data from Recurly (recurlySubscription) - // TODO: Delete this workaround once recurly cache property name migration rolled out. - if (personalSubscription) { - delete personalSubscription.recurly - } - function getPlanOnlyDisplayPrice( totalPlanPriceInCents, taxRate, @@ -251,8 +185,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { // The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce( (prev, curr) => { - return curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE - ? curr.quantity * curr.unit_amount_in_cents + prev + return curr.code !== MEMBERS_LIMIT_ADD_ON_CODE + ? curr.quantity * curr.unitPrice + prev : prev }, 0 @@ -261,24 +195,24 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { allAddOnsPriceInCentsExceptAdditionalLicensePrice + allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate - return SubscriptionFormatters.formatPriceLocalized( + return formatCurrency( totalPlanPriceInCents - allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice, - recurlySubscription.currency, + paymentRecord.subscription.currency, locale ) } function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) { return addOns.reduce((prev, curr) => { - if (curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE) { - const priceInCents = curr.quantity * curr.unit_amount_in_cents + if (curr.code !== MEMBERS_LIMIT_ADD_ON_CODE) { + const priceInCents = curr.quantity * curr.unitPrice const totalPriceInCents = priceInCents + priceInCents * taxRate if (totalPriceInCents > 0) { - prev[curr.add_on_code] = SubscriptionFormatters.formatPriceLocalized( + prev[curr.code] = formatCurrency( totalPriceInCents, - recurlySubscription.currency, + paymentRecord.subscription.currency, locale ) } @@ -288,124 +222,131 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { }, {}) } - if (personalSubscription && recurlySubscription) { - const tax = recurlySubscription.tax_in_cents || 0 + if (personalSubscription && paymentRecord && paymentRecord.subscription) { + // don't return subscription payment information + delete personalSubscription.paymentProvider + delete personalSubscription.recurly + + const tax = paymentRecord.subscription.taxAmount || 0 // Some plans allow adding more seats than the base plan provides. // This is recorded as a subscription add on. - // Note: tax_in_cents already includes the tax for any addon. + // Note: taxAmount already includes the tax for any addon. let addOnPrice = 0 let additionalLicenses = 0 - const addOns = recurlySubscription.subscription_add_ons || [] - const taxRate = recurlySubscription.tax_rate - ? parseFloat(recurlySubscription.tax_rate._) - : 0 + const addOns = paymentRecord.subscription.addOns || [] + const taxRate = paymentRecord.subscription.taxRate addOns.forEach(addOn => { - addOnPrice += addOn.quantity * addOn.unit_amount_in_cents - if (addOn.add_on_code === plan.membersLimitAddOn) { + addOnPrice += addOn.quantity * addOn.unitPrice + if (addOn.code === plan.membersLimitAddOn) { additionalLicenses += addOn.quantity } }) const totalLicenses = (plan.membersLimit || 0) + additionalLicenses - personalSubscription.recurly = { - tax, + const isInTrial = + paymentRecord.subscription.trialPeriodEnd && + paymentRecord.subscription.trialPeriodEnd.getTime() > Date.now() + personalSubscription.payment = { taxRate, - billingDetailsLink: buildHostedLink('billing-details'), + billingDetailsLink: + paymentRecord.subscription.service === 'recurly' + ? buildHostedLink('billing-details') + : null, accountManagementLink: buildHostedLink('account-management'), additionalLicenses, addOns, totalLicenses, nextPaymentDueAt: SubscriptionFormatters.formatDateTime( - recurlySubscription.current_period_ends_at + paymentRecord.subscription.periodEnd ), nextPaymentDueDate: SubscriptionFormatters.formatDate( - recurlySubscription.current_period_ends_at + paymentRecord.subscription.periodEnd ), - currency: recurlySubscription.currency, - state: recurlySubscription.state, + currency: paymentRecord.subscription.currency, + state: paymentRecord.subscription.state, trialEndsAtFormatted: SubscriptionFormatters.formatDateTime( - recurlySubscription.trial_ends_at + paymentRecord.subscription.trialPeriodEnd ), - trial_ends_at: recurlySubscription.trial_ends_at, - activeCoupons: recurlyCoupons, - account: recurlySubscription.account, - pausedAt: recurlySubscription.paused_at, - remainingPauseCycles: recurlySubscription.remaining_pause_cycles, + trialEndsAt: paymentRecord.subscription.trialPeriodEnd, + activeCoupons: paymentRecord.coupons, + accountEmail: paymentRecord.account.email, + hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice, + pausedAt: paymentRecord.subscription.pausePeriodStart, + remainingPauseCycles: paymentRecord.subscription.remainingPauseCycles, + isEligibleForPause: + paymentRecord.subscription.service === 'recurly' && + !personalSubscription.pendingPlan && + !personalSubscription.groupPlan && + !isInTrial && + !paymentRecord.subscription.planCode.includes('ann') && + !paymentRecord.subscription.addOns?.length > 0, + isEligibleForGroupPlan: + paymentRecord.subscription.service === 'recurly' && !isInTrial, } - if (recurlySubscription.pending_subscription) { - const pendingPlan = PlansLocator.findLocalPlanInSettings( - recurlySubscription.pending_subscription.plan.plan_code - ) + if (paymentRecord.subscription.pendingChange) { + const pendingPlanCode = + paymentRecord.subscription.pendingChange.nextPlanCode + const pendingPlan = PlansLocator.findLocalPlanInSettings(pendingPlanCode) if (pendingPlan == null) { - throw new Error( - `No plan found for planCode '${personalSubscription.planCode}'` - ) + throw new Error(`No plan found for planCode '${pendingPlanCode}'`) } let pendingAdditionalLicenses = 0 let pendingAddOnTax = 0 let pendingAddOnPrice = 0 - if (recurlySubscription.pending_subscription.subscription_add_ons) { - const pendingRecurlyAddons = - recurlySubscription.pending_subscription.subscription_add_ons - pendingRecurlyAddons.forEach(addOn => { - pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents - if (addOn.add_on_code === pendingPlan.membersLimitAddOn) { + if (paymentRecord.subscription.pendingChange.nextAddOns) { + const pendingAddOns = + paymentRecord.subscription.pendingChange.nextAddOns + pendingAddOns.forEach(addOn => { + pendingAddOnPrice += addOn.quantity * addOn.unitPrice + if (addOn.code === pendingPlan.membersLimitAddOn) { pendingAdditionalLicenses += addOn.quantity } }) // Need to calculate tax ourselves as we don't get tax amounts for pending subs pendingAddOnTax = - personalSubscription.recurly.taxRate * pendingAddOnPrice - pendingPlan.addOns = pendingRecurlyAddons + personalSubscription.payment.taxRate * pendingAddOnPrice + pendingPlan.addOns = pendingAddOns } const pendingSubscriptionTax = - personalSubscription.recurly.taxRate * - recurlySubscription.pending_subscription.unit_amount_in_cents - const totalPriceInCents = - recurlySubscription.pending_subscription.unit_amount_in_cents + + personalSubscription.payment.taxRate * + paymentRecord.subscription.pendingChange.nextPlanPrice + const totalPrice = + paymentRecord.subscription.pendingChange.nextPlanPrice + pendingAddOnPrice + pendingAddOnTax + pendingSubscriptionTax - personalSubscription.recurly.displayPrice = - SubscriptionFormatters.formatPriceLocalized( - totalPriceInCents, - recurlySubscription.currency, - locale - ) - personalSubscription.recurly.currentPlanDisplayPrice = - SubscriptionFormatters.formatPriceLocalized( - recurlySubscription.unit_amount_in_cents + addOnPrice + tax, - recurlySubscription.currency, - locale - ) - personalSubscription.recurly.planOnlyDisplayPrice = + + personalSubscription.payment.displayPrice = formatCurrency( + totalPrice, + paymentRecord.subscription.currency, + locale + ) + personalSubscription.payment.planOnlyDisplayPrice = getPlanOnlyDisplayPrice( - totalPriceInCents, + totalPrice, taxRate, - recurlySubscription.pending_subscription.subscription_add_ons + paymentRecord.subscription.pendingChange.nextAddOns ) - personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense = + personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense = getAddOnDisplayPricesWithoutAdditionalLicense( taxRate, - recurlySubscription.pending_subscription.subscription_add_ons + paymentRecord.subscription.pendingChange.nextAddOns ) const pendingTotalLicenses = (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses - personalSubscription.recurly.pendingAdditionalLicenses = + personalSubscription.payment.pendingAdditionalLicenses = pendingAdditionalLicenses - personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses + personalSubscription.payment.pendingTotalLicenses = pendingTotalLicenses personalSubscription.pendingPlan = pendingPlan } else { - const totalPriceInCents = - recurlySubscription.unit_amount_in_cents + addOnPrice + tax - personalSubscription.recurly.displayPrice = - SubscriptionFormatters.formatPriceLocalized( - totalPriceInCents, - recurlySubscription.currency, - locale - ) - personalSubscription.recurly.planOnlyDisplayPrice = - getPlanOnlyDisplayPrice(totalPriceInCents, taxRate, addOns) - personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense = + const totalPrice = paymentRecord.subscription.planPrice + addOnPrice + tax + personalSubscription.payment.displayPrice = formatCurrency( + totalPrice, + paymentRecord.subscription.currency, + locale + ) + personalSubscription.payment.planOnlyDisplayPrice = + getPlanOnlyDisplayPrice(totalPrice, taxRate, addOns) + personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense = getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns) } } @@ -645,7 +586,6 @@ module.exports = { getBestSubscription: callbackify(getBestSubscription), promises: { buildUsersSubscriptionViewModel, - getRedirectToHostedPage, getBestSubscription, }, } diff --git a/services/web/app/src/Features/Subscription/types.ts b/services/web/app/src/Features/Subscription/types.ts index b4989c49ea..59453ac47c 100644 --- a/services/web/app/src/Features/Subscription/types.ts +++ b/services/web/app/src/Features/Subscription/types.ts @@ -1,3 +1,6 @@ -import { PaypalPaymentMethod, CreditCardPaymentMethod } from './RecurlyEntities' +import { + PaypalPaymentMethod, + CreditCardPaymentMethod, +} from './PaymentProviderEntities' export type PaymentMethod = PaypalPaymentMethod | CreditCardPaymentMethod diff --git a/services/web/app/src/Features/Survey/SurveyHandler.mjs b/services/web/app/src/Features/Survey/SurveyHandler.mjs index 009bc12a95..7ce89594c6 100644 --- a/services/web/app/src/Features/Survey/SurveyHandler.mjs +++ b/services/web/app/src/Features/Survey/SurveyHandler.mjs @@ -4,6 +4,7 @@ import crypto from 'node:crypto' import SurveyCache from './SurveyCache.mjs' import SubscriptionLocator from '../Subscription/SubscriptionLocator.js' import { callbackify } from '@overleaf/promise-utils' +import UserGetter from '../User/UserGetter.js' /** * @import { Survey } from '../../../../types/project/dashboard/survey' @@ -33,6 +34,25 @@ async function getSurvey(userId) { return } + const { earliestSignupDate, latestSignupDate } = survey.options || {} + if (earliestSignupDate || latestSignupDate) { + const user = await UserGetter.promises.getUser(userId, { signUpDate: 1 }) + if (!user) { + return + } + const { signUpDate } = user + if (latestSignupDate) { + // Make the check inclusive + latestSignupDate.setHours(23, 59, 59, 999) + if (signUpDate > latestSignupDate) { + return + } + } + if (earliestSignupDate && signUpDate < earliestSignupDate) { + return + } + } + return { name, preText, linkText, url } } } diff --git a/services/web/app/src/Features/Survey/SurveyManager.js b/services/web/app/src/Features/Survey/SurveyManager.js index abaee90f13..3d6f225e10 100644 --- a/services/web/app/src/Features/Survey/SurveyManager.js +++ b/services/web/app/src/Features/Survey/SurveyManager.js @@ -10,6 +10,7 @@ async function getSurvey() { } async function updateSurvey({ name, preText, linkText, url, options }) { + validateOptions(options) let survey = await getSurvey() if (!survey) { survey = new Survey() @@ -23,6 +24,41 @@ async function updateSurvey({ name, preText, linkText, url, options }) { return survey } +function validateOptions(options) { + if (!options) { + return + } + if (typeof options !== 'object') { + throw new Error('options must be an object') + } + const { earliestSignupDate, latestSignupDate } = options + + const earliestDate = parseDate(earliestSignupDate) + const latestDate = parseDate(latestSignupDate) + if (earliestDate && latestDate) { + if (earliestDate > latestDate) { + throw new Error('earliestSignupDate must be before latestSignupDate') + } + } +} + +function parseDate(date) { + if (date) { + if (typeof date !== 'string') { + throw new Error('Date must be a string') + } + if (date.match(/^\d{4}-\d{2}-\d{2}$/) === null) { + throw new Error('Date must be in YYYY-MM-DD format') + } + const asDate = new Date(date) + if (isNaN(asDate.getTime())) { + throw new Error('Date must be a valid date') + } + return asDate + } + return null +} + async function deleteSurvey() { const survey = await getSurvey() if (survey) { diff --git a/services/web/app/src/Features/Templates/TemplatesController.js b/services/web/app/src/Features/Templates/TemplatesController.js index ac82d0c5d3..a8730a61be 100644 --- a/services/web/app/src/Features/Templates/TemplatesController.js +++ b/services/web/app/src/Features/Templates/TemplatesController.js @@ -4,9 +4,13 @@ const TemplatesManager = require('./TemplatesManager') const ProjectHelper = require('../Project/ProjectHelper') const logger = require('@overleaf/logger') const { expressify } = require('@overleaf/promise-utils') +const SplitTestHandler = require('../SplitTests/SplitTestHandler') const TemplatesController = { - getV1Template(req, res) { + async getV1Template(req, res) { + // Read split test assignment so that it's available for Pug to read + await SplitTestHandler.promises.getAssignment(req, res, 'core-pug-bs5') + const templateVersionId = req.params.Template_version_id const templateId = req.query.id if (!/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)) { @@ -25,7 +29,7 @@ const TemplatesController = { mainFile: req.query.mainFile, brandVariationId: req.query.brandVariationId, } - return res.render( + res.render( path.resolve( __dirname, '../../../views/project/editor/new_from_template' @@ -55,7 +59,7 @@ const TemplatesController = { } module.exports = { - getV1Template: TemplatesController.getV1Template, + getV1Template: expressify(TemplatesController.getV1Template), createProjectFromV1Template: expressify( TemplatesController.createProjectFromV1Template ), diff --git a/services/web/app/src/Features/Templates/TemplatesManager.js b/services/web/app/src/Features/Templates/TemplatesManager.js index f105b6ae85..6a2b6207c1 100644 --- a/services/web/app/src/Features/Templates/TemplatesManager.js +++ b/services/web/app/src/Features/Templates/TemplatesManager.js @@ -17,6 +17,7 @@ const settings = require('@overleaf/settings') const crypto = require('crypto') const Errors = require('../Errors/Errors') const { pipeline } = require('stream/promises') +const ClsiCacheManager = require('../Compile/ClsiCacheManager') const TemplatesManager = { async createProjectFromV1Template( @@ -63,6 +64,17 @@ const TemplatesManager = { attributes ) + const prepareClsiCacheInBackground = ClsiCacheManager.prepareClsiCache( + project._id, + userId, + { templateId, templateVersionId } + ).catch(err => { + logger.warn( + { err, templateId, templateVersionId, projectId: project._id }, + 'failed to prepare clsi-cache from template' + ) + }) + await TemplatesManager._setCompiler(project._id, compiler) await TemplatesManager._setImage(project._id, imageName) await TemplatesManager._setMainFile(project._id, mainFile) @@ -74,6 +86,8 @@ const TemplatesManager = { } await Project.updateOne({ _id: project._id }, update, {}) + await prepareClsiCacheInBackground + return project } finally { await fs.promises.unlink(dumpPath) diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs index c17b54ff1e..219db88b12 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs +++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs @@ -180,7 +180,11 @@ async function createFolder(userId, projectId, projectName, path) { return null } - const folder = await UpdateMerger.promises.createFolder(project._id, path) + const folder = await UpdateMerger.promises.createFolder( + project._id, + path, + userId + ) return { folderId: folder._id, parentFolderId: folder.parentFolder_id, diff --git a/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js b/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js index e67d5fc30d..f68d366d8e 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js +++ b/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js @@ -176,10 +176,11 @@ async function _readFileIntoTextArray(path) { return lines } -async function createFolder(projectId, path) { +async function createFolder(projectId, path, userId) { const { lastFolder: folder } = await EditorController.promises.mkdirp( projectId, - path + path, + userId ) return folder } diff --git a/services/web/app/src/Features/Tutorial/TutorialController.mjs b/services/web/app/src/Features/Tutorial/TutorialController.mjs index 3672f8db0d..a5cdf8d478 100644 --- a/services/web/app/src/Features/Tutorial/TutorialController.mjs +++ b/services/web/app/src/Features/Tutorial/TutorialController.mjs @@ -8,10 +8,11 @@ const VALID_KEYS = [ 'writefull-oauth-promotion', 'bib-file-tpr-prompt', 'ai-error-assistant-consent', - 'code-editor-mode-prompt', 'history-restore-promo', 'us-gov-banner', 'us-gov-banner-fedramp', + 'full-project-search-promo', + 'editor-popup-ux-survey', ] async function completeTutorial(req, res, next) { diff --git a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs index da2847d108..a3bc434ed7 100644 --- a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs +++ b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs @@ -68,6 +68,7 @@ async function uploadFile(req, res, next) { const name = req.body.name const path = req.file?.path const projectId = req.params.Project_id + const userId = SessionManager.getLoggedInUserId(req.session) let { folder_id: folderId } = req.query if (name == null || name.length === 0 || name.length > 150) { return res.status(422).json({ @@ -87,13 +88,12 @@ async function uploadFile(req, res, next) { }) const { lastFolder } = await EditorController.promises.mkdirp( projectId, - Path.dirname(Path.join('/', path.fileSystem, relativePath)) + Path.dirname(Path.join('/', path.fileSystem, relativePath)), + userId ) folderId = lastFolder._id } - const userId = SessionManager.getLoggedInUserId(req.session) - return FileSystemImportManager.addEntity( userId, projectId, diff --git a/services/web/app/src/Features/User/UserDeleter.js b/services/web/app/src/Features/User/UserDeleter.js index 4009419ffe..721943b163 100644 --- a/services/web/app/src/Features/User/UserDeleter.js +++ b/services/web/app/src/Features/User/UserDeleter.js @@ -43,21 +43,28 @@ async function deleteUser(userId, options) { try { const user = await User.findById(userId).exec() - logger.debug({ user }, 'deleting user') - + logger.info({ userId }, 'deleting user') await ensureCanDeleteUser(user) + logger.info({ userId }, 'cleaning up user') await _cleanupUser(user) + logger.info({ userId }, 'firing deleteUser hook') await Modules.promises.hooks.fire('deleteUser', userId) + logger.info({ userId }, 'adding delete-account audit log entry') await UserAuditLogHandler.promises.addEntry( userId, 'delete-account', options.deleterUser ? options.deleterUser._id : userId, options.ipAddress ) + logger.info({ userId }, 'creating deleted user record') await _createDeletedUser(user, options) + logger.info({ userId }, 'deleting user projects') await ProjectDeleter.promises.deleteUsersProjects(user._id) + logger.info({ userId }, 'sending deletion email to user') await _sendDeleteEmail(user, options.force) + logger.info({ userId }, 'deleting user record') await deleteMongoUser(user._id) + logger.info({ userId }, 'user deletion complete') } catch (error) { logger.warn({ error, userId }, 'something went wrong deleting the user') throw error @@ -161,13 +168,22 @@ async function _createDeletedUser(user, options) { } async function _cleanupUser(user) { + const userId = user._id + + logger.info({ userId }, '[cleanupUser] removing user sessions from Redis') await UserSessionsManager.promises.removeSessionsFromRedis(user) + logger.info({ userId }, '[cleanupUser] unsubscribing from newsletters') await NewsletterManager.promises.unsubscribe(user, { delete: true }) + logger.info({ userId }, '[cleanupUser] cancelling subscription') await SubscriptionHandler.promises.cancelSubscription(user) - await InstitutionsAPI.promises.deleteAffiliations(user._id) - await SubscriptionUpdater.promises.removeUserFromAllGroups(user._id) - await UserMembershipsHandler.promises.removeUserFromAllEntities(user._id) - await Modules.promises.hooks.fire('cleanupPersonalAccessTokens', user._id, [ + logger.info({ userId }, '[cleanupUser] deleting affiliations') + await InstitutionsAPI.promises.deleteAffiliations(userId) + logger.info({ userId }, '[cleanupUser] removing user from groups') + await SubscriptionUpdater.promises.removeUserFromAllGroups(userId) + logger.info({ userId }, '[cleanupUser] removing user from memberships') + await UserMembershipsHandler.promises.removeUserFromAllEntities(userId) + logger.info({ userId }, '[cleanupUser] removing personal access tokens') + await Modules.promises.hooks.fire('cleanupPersonalAccessTokens', userId, [ 'collabratec', 'git_bridge', ]) diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs index cd456a4377..6f7bb7802d 100644 --- a/services/web/app/src/Features/User/UserPagesController.mjs +++ b/services/web/app/src/Features/User/UserPagesController.mjs @@ -117,11 +117,6 @@ async function settingsPage(req, res) { ) } - // Get the users write-and-cite assignment to switch between translation strings - await SplitTestHandler.promises.getAssignment(req, res, 'write-and-cite') - // Get the users papers-integration assignment to show the linking widget - await SplitTestHandler.promises.getAssignment(req, res, 'papers-integration') - res.render('user/settings', { title: 'account_settings', user: { @@ -154,6 +149,7 @@ async function settingsPage(req, res) { enabled: Boolean(user.aiErrorAssistant?.enabled), }, }, + labsExperiments: user.labsExperiments ?? [], hasPassword: !!user.hashedPassword, shouldAllowEditingDetails, oauthProviders: UserPagesController._translateProviderDescriptions( diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js index 70753dd38d..aa7aa4ac44 100644 --- a/services/web/app/src/infrastructure/mongodb.js +++ b/services/web/app/src/infrastructure/mongodb.js @@ -81,6 +81,7 @@ const db = { userAuditLogEntries: internalDb.collection('userAuditLogEntries'), users: internalDb.collection('users'), onboardingDataCollection: internalDb.collection('onboardingDataCollection'), + scriptLogs: internalDb.collection('scriptLogs'), } const connectionPromise = mongoClient.connect() diff --git a/services/web/app/src/models/ScriptLog.mjs b/services/web/app/src/models/ScriptLog.mjs new file mode 100644 index 0000000000..9cc6b8655f --- /dev/null +++ b/services/web/app/src/models/ScriptLog.mjs @@ -0,0 +1,27 @@ +import Mongoose from '../infrastructure/Mongoose.js' + +export const ScriptLogSchema = new Mongoose.Schema( + { + canonicalName: { type: String, required: true }, + filePathAtVersion: { type: String, required: true }, + imageVersion: { type: String, required: true }, + podName: { type: String, required: true }, + startTime: { type: Date, default: Date.now }, + endTime: { type: Date, default: null }, + username: { type: String, required: true }, + status: { + type: String, + enum: ['pending', 'success', 'error'], + default: 'pending', + required: true, + }, + vars: { type: Object, required: true }, + progressLogs: { + type: [{ timestamp: Date, message: String }], + required: true, + }, + }, + { minimize: false, collection: 'scriptLogs' } +) + +export const ScriptLog = Mongoose.model('ScriptLog', ScriptLogSchema) diff --git a/services/web/app/src/models/Subscription.js b/services/web/app/src/models/Subscription.js index aae114846c..92a7739515 100644 --- a/services/web/app/src/models/Subscription.js +++ b/services/web/app/src/models/Subscription.js @@ -57,6 +57,23 @@ const SubscriptionSchema = new Schema( type: Date, }, }, + paymentProvider: { + service: { + type: String, + }, + subscriptionId: { + type: String, + }, + state: { + type: String, + }, + trialStartedAt: { + type: Date, + }, + trialEndsAt: { + type: Date, + }, + }, collectionMethod: { type: String, enum: ['automatic', 'manual'], diff --git a/services/web/app/src/models/Survey.js b/services/web/app/src/models/Survey.js index 5d56a20b54..deb5d60454 100644 --- a/services/web/app/src/models/Survey.js +++ b/services/web/app/src/models/Survey.js @@ -36,6 +36,12 @@ const SurveySchema = new Schema( type: Boolean, default: false, }, + earliestSignupDate: { + type: Date, + }, + latestSignupDate: { + type: Date, + }, rolloutPercentage: { type: Number, default: 100, diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index 73506f161c..c63647e914 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -203,6 +203,7 @@ const UserSchema = new Schema( alphaProgram: { type: Boolean, default: false }, // experimental features betaProgram: { type: Boolean, default: false }, labsProgram: { type: Boolean, default: false }, + labsExperiments: { type: Array, default: [] }, overleaf: { id: { type: Number }, accessToken: { type: String }, diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 830620506a..5e1a21c063 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -66,6 +66,7 @@ import _ from 'lodash' import { plainTextResponse } from './infrastructure/Response.js' import PublicAccessLevels from './Features/Authorization/PublicAccessLevels.js' import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs' +import ClsiCacheController from './Features/Compile/ClsiCacheController.js' const ClsiCookieManager = ClsiCookieManagerFactory( Settings.apis.clsi != null ? Settings.apis.clsi.backendGroupName : undefined ) @@ -606,36 +607,21 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CompileController.stopCompile ) - // LEGACY: Used by the web download buttons, adds filename header, TODO: remove at some future date webRouter.get( - '/project/:Project_id/output/output.pdf', + '/project/:Project_id/output/cached/output.overleaf.json', AuthorizationMiddleware.ensureUserCanReadProject, - CompileController.downloadPdf + ClsiCacheController.getLatestBuildFromCache ) - // PDF Download button webRouter.get( - /^\/download\/project\/([^/]*)\/output\/output\.pdf$/, - function (req, res, next) { - const params = { Project_id: req.params[0] } - req.params = params - next() - }, + '/download/project/:Project_id/build/:buildId/output/cached/:filename', AuthorizationMiddleware.ensureUserCanReadProject, - CompileController.downloadPdf + ClsiCacheController.downloadFromCache ) // PDF Download button for specific build webRouter.get( - /^\/download\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/output\.pdf$/, - function (req, res, next) { - const params = { - Project_id: req.params[0], - build_id: req.params[1], - } - req.params = params - next() - }, + '/download/project/:Project_id/build/:build_id/output/output.pdf', AuthorizationMiddleware.ensureUserCanReadProject, CompileController.downloadPdf ) @@ -646,22 +632,7 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { { params: ['Project_id'] } ) - // Used by the pdf viewers - webRouter.get( - /^\/project\/([^/]*)\/output\/(.*)$/, - function (req, res, next) { - const params = { - Project_id: req.params[0], - file: req.params[1], - } - req.params = params - next() - }, - rateLimiterMiddlewareOutputFiles, - AuthorizationMiddleware.ensureUserCanReadProject, - CompileController.getFileFromClsi - ) - // direct url access to output files for a specific build (query string not required) + // direct url access to output files for a specific build webRouter.get( /^\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/, function (req, res, next) { @@ -678,24 +649,7 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CompileController.getFileFromClsi ) - // direct url access to output files for user but no build, to retrieve files when build fails - webRouter.get( - /^\/project\/([^/]*)\/user\/([0-9a-f-]+)\/output\/(.*)$/, - function (req, res, next) { - const params = { - Project_id: req.params[0], - user_id: req.params[1], - file: req.params[2], - } - req.params = params - next() - }, - rateLimiterMiddlewareOutputFiles, - AuthorizationMiddleware.ensureUserCanReadProject, - CompileController.getFileFromClsi - ) - - // direct url access to output files for a specific user and build (query string not required) + // direct url access to output files for a specific user and build webRouter.get( /^\/project\/([^/]*)\/user\/([0-9a-f]+)\/build\/([0-9a-f-]+)\/output\/(.*)$/, function (req, res, next) { @@ -991,20 +945,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { InactiveProjectController.deactivateProject ) - privateApiRouter.get( - /^\/internal\/project\/([^/]*)\/output\/(.*)$/, - function (req, res, next) { - const params = { - Project_id: req.params[0], - file: req.params[1], - } - req.params = params - next() - }, - AuthenticationController.requirePrivateApiAuth(), - CompileController.getFileFromClsi - ) - privateApiRouter.get( '/project/:Project_id/doc/:doc_id', AuthenticationController.requirePrivateApiAuth(), diff --git a/services/web/app/views/_mixins/quote.pug b/services/web/app/views/_mixins/quote.pug index 1590833e62..b8065dbdab 100644 --- a/services/web/app/views/_mixins/quote.pug +++ b/services/web/app/views/_mixins/quote.pug @@ -32,7 +32,8 @@ mixin collinsQuote1 -var quotePerson = 'Christopher Collins' -var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' -var quotePersonImg = buildImgPath("advocates/collins.jpg") - +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) + .card-body + +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) mixin collinsQuote2 .card.card-dark-green-bg @@ -40,7 +41,8 @@ mixin collinsQuote2 -var quotePerson = 'Christopher Collins' -var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' -var quotePersonImg = buildImgPath("advocates/collins.jpg") - +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) + .card-body + +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) mixin bennettQuote1 .card.card-dark-green-bg @@ -48,4 +50,5 @@ mixin bennettQuote1 -var quotePerson = 'Andrew Bennett' -var quotePersonPosition = 'Software Architect, Symplectic' -var quotePersonImg = buildImgPath("advocates/bennett.jpg") - +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) \ No newline at end of file + .card-body + +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) \ No newline at end of file diff --git a/services/web/app/views/layout-react.pug b/services/web/app/views/layout-react.pug index 4fba24c226..f3dc8e6a06 100644 --- a/services/web/app/views/layout-react.pug +++ b/services/web/app/views/layout-react.pug @@ -19,6 +19,7 @@ block append meta - const staffAccess = sessionUser?.staffAccess - const canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement) - const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu + - const canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu - const enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on') - const showSignUpLink = hasFeature('registration-page') @@ -29,6 +30,7 @@ block append meta canDisplayAdminRedirect, canDisplaySplitTestMenu, canDisplaySurveyMenu, + canDisplayScriptLogMenu, enableUpgradeButton, suppressNavbarRight: !!suppressNavbarRight, suppressNavContentLinks: !!suppressNavContentLinks, diff --git a/services/web/app/views/layout/fat-footer-website-redesign.pug b/services/web/app/views/layout/fat-footer-website-redesign.pug index bde9113c75..cd68e1daf6 100644 --- a/services/web/app/views/layout/fat-footer-website-redesign.pug +++ b/services/web/app/views/layout/fat-footer-website-redesign.pug @@ -13,7 +13,7 @@ footer.fat-footer.hidden-print.website-redesign-fat-footer li a(href="/about/values") #{translate('our_values')} li - a(href="/about/careers") #{translate('careers')} + a(href="https://digitalscience.pinpointhq.com/") #{translate('careers')} li a(href="/for/press") !{translate('press_and_awards')} li diff --git a/services/web/app/views/layout/fat-footer.pug b/services/web/app/views/layout/fat-footer.pug index 95de4a3567..d319a217cb 100644 --- a/services/web/app/views/layout/fat-footer.pug +++ b/services/web/app/views/layout/fat-footer.pug @@ -13,7 +13,7 @@ footer.fat-footer.hidden-print li a(href="/about/values") #{translate('our_values')} li - a(href="/about/careers") #{translate('careers')} + a(href="https://digitalscience.pinpointhq.com/") #{translate('careers')} li a(href="/for/press") !{translate('press_and_awards')} li diff --git a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug index 1c527c93d2..ee94394bc4 100644 --- a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug +++ b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug @@ -27,6 +27,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ - var canDisplayAdminRedirect = canRedirectToAdminDomain() - var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement))) - var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu + - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu if (typeof suppressNavbarRight === "undefined") button.navbar-toggler.collapsed( @@ -66,6 +67,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ +dropdown-menu-link-item()(href="/admin/split-test") Manage Feature Flags if canDisplaySurveyMenu +dropdown-menu-link-item()(href="/admin/survey") Manage Surveys + if canDisplayScriptLogMenu + +dropdown-menu-link-item()(href="/admin/script-logs") View Script Logs // loop over header_extras each item in nav.header_extras diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index ebf698f726..e0f36004b8 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -1,4 +1,6 @@ -nav.navbar.navbar-default.navbar-main +nav.navbar.navbar-default.navbar-main(class={ + 'website-redesign-navbar': isWebsiteRedesign +}) .container-fluid .navbar-header if (typeof(suppressNavbarRight) == "undefined") @@ -30,6 +32,7 @@ nav.navbar.navbar-default.navbar-main - var canDisplayAdminRedirect = canRedirectToAdminDomain() - var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement))) - var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu + - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu if (typeof(suppressNavbarRight) == "undefined") .navbar-collapse.collapse#navbar-main-collapse @@ -66,6 +69,9 @@ nav.navbar.navbar-default.navbar-main if canDisplaySurveyMenu li a(href="/admin/survey") Manage Surveys + if canDisplayScriptLogMenu + li + a(href="/admin/script-logs") View Script Logs // loop over header_extras each item in nav.header_extras diff --git a/services/web/app/views/layout/navbar-website-redesign.pug b/services/web/app/views/layout/navbar-website-redesign.pug index 3e18ca94bc..c4b712e955 100644 --- a/services/web/app/views/layout/navbar-website-redesign.pug +++ b/services/web/app/views/layout/navbar-website-redesign.pug @@ -30,6 +30,7 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar - var canDisplayAdminRedirect = canRedirectToAdminDomain() - var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement))) - var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu + - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu if (typeof(suppressNavbarRight) == "undefined") .navbar-collapse.collapse#navbar-main-collapse @@ -66,6 +67,9 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar if canDisplaySurveyMenu li a(href="/admin/survey") Manage Surveys + if canDisplayScriptLogMenu + li + a(href="/admin/script-logs") View Script Logs // loop over header_extras each item in nav.header_extras diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 720802aba8..ab31cef89e 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -1,7 +1,9 @@ meta(name="ol-project_id" content=project_id) meta(name="ol-projectName" content=projectName) +meta(name="ol-projectOwnerHasPremiumOnPageLoad" data-type="boolean" content=projectOwnerHasPremiumOnPageLoad) meta(name="ol-userSettings" data-type="json" content=userSettings) meta(name="ol-user" data-type="json" content=user) +meta(name="ol-labsExperiments" data-type="json" content=labsExperiments) meta(name="ol-learnedWords" data-type="json" content=learnedWords) meta(name="ol-anonymous" data-type="boolean" content=anonymous) meta(name="ol-brandVariation" data-type="json" content=brandVariation) @@ -35,7 +37,6 @@ meta(name="ol-showTemplatesServerPro", data-type="boolean" content=showTemplates meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature) meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials) meta(name="ol-projectTags" data-type="json" content=projectTags) -meta(name="ol-usedLatex" data-type="string" content=usedLatex) meta(name="ol-ro-mirror-on-client-no-local-storage" data-type="boolean" content=roMirrorOnClientNoLocalStorage) meta(name="ol-isSaas" data-type="boolean" content=isSaas) meta(name="ol-shouldLoadHotjar" data-type="boolean" content=shouldLoadHotjar) diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index c3b40cecf5..f2945b20f1 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -4,7 +4,8 @@ block vars - var suppressFooter = true - var suppressCookieBanner = true - var suppressSkipToContent = true - - bootstrap5PageStatus = 'disabled' + - bootstrap5PageStatus = 'enabled' + - bootstrap5PageSplitTest = 'core-pug-bs5' block content .editor.full-size diff --git a/services/web/app/views/project/invite/not-valid.pug b/services/web/app/views/project/invite/not-valid.pug index ac3eaed80a..693c162205 100644 --- a/services/web/app/views/project/invite/not-valid.pug +++ b/services/web/app/views/project/invite/not-valid.pug @@ -1,21 +1,22 @@ extends ../../layout-marketing block vars - - bootstrap5PageStatus = 'disabled' + - bootstrap5PageStatus = 'enabled' + - bootstrap5PageSplitTest = 'core-pug-bs5' block content main.content.content-alt#main-content .container .row - .col-md-8.col-md-offset-2 + .col-md-8.col-md-offset-2.offset-md-2 .card.project-invite-invalid - .page-header.text-centered - h1 #{translate("invite_not_valid")} - .row.text-center - .col-md-12 - p - | #{translate("invite_not_valid_description")}. - .row.text-center.actions - .col-md-12 - a.btn.btn-secondary-info.btn-secondary(href="/project") #{translate("back_to_your_projects")} - + .card-body + .page-header.text-center + h1 #{translate("invite_not_valid")} + .row.text-center + .col-12.col-md-12 + p + | #{translate("invite_not_valid_description")}. + .row.text-center.actions + .col-12.col-md-12 + a.btn.btn-secondary-info.btn-secondary(href="/project") #{translate("back_to_your_projects")} diff --git a/services/web/app/views/project/invite/show.pug b/services/web/app/views/project/invite/show.pug index 8d19a1edba..35926977e2 100644 --- a/services/web/app/views/project/invite/show.pug +++ b/services/web/app/views/project/invite/show.pug @@ -1,37 +1,39 @@ extends ../../layout-marketing block vars - - bootstrap5PageStatus = 'disabled' + - bootstrap5PageStatus = 'enabled' + - bootstrap5PageSplitTest = 'core-pug-bs5' block content main.content.content-alt#main-content .container .row - .col-md-8.col-md-offset-2 + .col-12.col-md-8.col-md-offset-2.offset-md-2 .card.project-invite-accept - .page-header.text-centered - h1 #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})} - br - em #{project.name} - .row.text-center - .col-md-12 - p - | #{translate("accepting_invite_as")}  - em #{user.email} - .row - .col-md-12 - form.form( - data-ol-regular-form - method="POST", - action="/project/"+invite.projectId+"/invite/token/"+token+"/accept" - ) - input(name='_csrf', type='hidden', value=csrfToken) - input(name='token', type='hidden', value=token) - .form-group.text-center - button.btn.btn-lg.btn-primary( - type="submit" - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("join_project")} - span(hidden data-ol-inflight="pending") #{translate("joining")}… - .form-group.text-center + .card-body + .page-header.text-center + h1 #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})} + br + em #{project.name} + .row.text-center + .col-12.col-md-12 + p + | #{translate("accepting_invite_as")}  + em #{user.email} + .row + .col-12.col-md-12 + form.form( + data-ol-regular-form + method="POST", + action="/project/"+invite.projectId+"/invite/token/"+token+"/accept" + ) + input(name='_csrf', type='hidden', value=csrfToken) + input(name='token', type='hidden', value=token) + .form-group.text-center + button.btn.btn-lg.btn-primary( + type="submit" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("join_project")} + span(hidden data-ol-inflight="pending") #{translate("joining")}… + .form-group.text-center diff --git a/services/web/app/views/subscriptions/add-seats.pug b/services/web/app/views/subscriptions/add-seats.pug index fb04ef91fb..697a554c97 100644 --- a/services/web/app/views/subscriptions/add-seats.pug +++ b/services/web/app/views/subscriptions/add-seats.pug @@ -4,11 +4,11 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/add-seats' block append meta - meta(name="ol-subscriptionData" data-type="json" content=subscriptionData) meta(name="ol-groupName", data-type="string", content=groupName) meta(name="ol-subscriptionId", data-type="string", content=subscriptionId) meta(name="ol-totalLicenses", data-type="number", content=totalLicenses) meta(name="ol-isProfessional", data-type="boolean", content=isProfessional) + meta(name="ol-isCollectionMethodManual", data-type="boolean", content=isCollectionMethodManual) block content main.content.content-alt#main-content diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index aa3e597dc5..dab505e4e5 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -1,33 +1,34 @@ extends ../layout-react block entrypointVar - - entrypoint = 'pages/user/subscription/dashboard' + - entrypoint = 'pages/user/subscription/dashboard' block head-scripts - script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js") + script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js") block append meta - meta(name="ol-subscription" data-type="json" content=personalSubscription) - meta(name="ol-userCanExtendTrial" data-type="boolean" content=userCanExtendTrial) - meta(name="ol-managedGroupSubscriptions" data-type="json" content=managedGroupSubscriptions) - meta(name="ol-memberGroupSubscriptions" data-type="json" content=memberGroupSubscriptions) - meta(name="ol-managedInstitutions" data-type="json" content=managedInstitutions) - meta(name="ol-managedPublishers" data-type="json" content=managedPublishers) - meta(name="ol-planCodesChangingAtTermEnd" data-type="json", content=planCodesChangingAtTermEnd) - meta(name="ol-currentInstitutionsWithLicence" data-type="json" content=currentInstitutionsWithLicence) - meta(name="ol-hasSubscription" data-type="boolean" content=hasSubscription) - meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage) - meta(name="ol-plans", data-type="json" content=plans) - meta(name="ol-groupSettingsAdvertisedFor", data-type="json" content=groupSettingsAdvertisedFor) - meta(name="ol-canUseFlexibleLicensing", data-type="boolean", content=canUseFlexibleLicensing) - meta(name="ol-showGroupDiscount", data-type="boolean", content=showGroupDiscount) - meta(name="ol-groupSettingsEnabledFor", data-type="json" content=groupSettingsEnabledFor) - meta(name="ol-user" data-type="json" content=user) - if (personalSubscription && personalSubscription.recurly) - meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) - meta(name="ol-recommendedCurrency" content=personalSubscription.recurly.currency) - meta(name="ol-groupPlans" data-type="json" content=groupPlans) + meta(name="ol-subscription" data-type="json" content=personalSubscription) + meta(name="ol-userCanExtendTrial" data-type="boolean" content=userCanExtendTrial) + meta(name="ol-managedGroupSubscriptions" data-type="json" content=managedGroupSubscriptions) + meta(name="ol-memberGroupSubscriptions" data-type="json" content=memberGroupSubscriptions) + meta(name="ol-managedInstitutions" data-type="json" content=managedInstitutions) + meta(name="ol-managedPublishers" data-type="json" content=managedPublishers) + meta(name="ol-planCodesChangingAtTermEnd" data-type="json", content=planCodesChangingAtTermEnd) + meta(name="ol-currentInstitutionsWithLicence" data-type="json" content=currentInstitutionsWithLicence) + meta(name="ol-hasSubscription" data-type="boolean" content=hasSubscription) + meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage) + meta(name="ol-plans" data-type="json" content=plans) + meta(name="ol-groupSettingsAdvertisedFor" data-type="json" content=groupSettingsAdvertisedFor) + meta(name="ol-canUseFlexibleLicensing" data-type="boolean", content=canUseFlexibleLicensing) + meta(name="ol-showGroupDiscount" data-type="boolean", content=showGroupDiscount) + meta(name="ol-groupSettingsEnabledFor" data-type="json" content=groupSettingsEnabledFor) + meta(name="ol-hasAiAssistViaWritefull" data-type="boolean", content=hasAiAssistViaWritefull) + meta(name="ol-user" data-type="json" content=user) + if (personalSubscription && personalSubscription.payment) + meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) + meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency) + meta(name="ol-groupPlans" data-type="json" content=groupPlans) block content - main.content.content-alt#main-content - #subscription-dashboard-root + main.content.content-alt#main-content + #subscription-dashboard-root diff --git a/services/web/app/views/subscriptions/plans/_faq_new.pug b/services/web/app/views/subscriptions/plans/_faq_new.pug index 823931111f..baefb6ed3f 100644 --- a/services/web/app/views/subscriptions/plans/_faq_new.pug +++ b/services/web/app/views/subscriptions/plans/_faq_new.pug @@ -13,21 +13,40 @@ include ../../_mixins/eyebrow .row .col-xs-12 - .ol-tabs-scrollable + div( + class={ + 'plans-faq-tabs': bootstrapVersion === 5, + 'ol-tabs': bootstrapVersion === 5, + 'ol-tabs-scrollable': bootstrapVersion === 3 + } + ) .nav-tabs-container ul.nav.nav-tabs(role="tablist") - li.active(role="presentation") + //- In the bs5 version of plans page, the `active` class need to be added to the `a` tag instead of the parent `li` tag + //- If the `plans-page-bs5` split test has been completed, remove the `active` class from the `li` tag since we're not using it anymore + //- If the `plans-page-bs5` split test has been completed, remove the `data-toggle` because it is not needed anymore (bs5 uses `data-bs-toggle`) + li( + role="presentation" + class={ + active: bootstrapVersion === 3 + } + ) a( role="tab" data-toggle="tab" + data-bs-toggle="tab" href='#' + managingYourSubscription aria-controls=managingYourSubscription + class={ + active: bootstrapVersion === 5 + } ) | #{translate('managing_your_subscription')} li(role="presentation") a( role="tab" data-toggle="tab" + data-bs-toggle="tab" href='#' + overleafIndividualPlans aria-controls=overleafIndividualPlans ) @@ -36,6 +55,7 @@ include ../../_mixins/eyebrow a( role="tab" data-toggle="tab" + data-bs-toggle="tab" href='#' + overleafGroupPlans aria-controls=overleafGroupPlans ) @@ -58,8 +78,9 @@ include ../../_mixins/eyebrow ) +overleafGroupPlans() - .row.plans-faq-support - span #{translate('still_have_questions')} - button(data-ol-open-contact-form-modal="general") - span(style="margin-right: 4px") #{translate('contact_support')} - i.icon-md.material-symbols.material-symbols-rounded.material-symbols-arrow-right(aria-hidden="true") arrow_right_alt + .row + .col-xs-12.plans-faq-support + span #{translate('still_have_questions')} + button(data-ol-open-contact-form-modal="general") + span(style="margin-right: 4px") #{translate('contact_support')} + i.icon-md.material-symbols.material-symbols-rounded.material-symbols-arrow-right(aria-hidden="true") arrow_right_alt diff --git a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug index 7f41c4d83e..f312ebeb46 100644 --- a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug +++ b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug @@ -1,7 +1,17 @@ +//- If the `plans-page-bs5` split test has been completed, remove the `data-toggle` and `data-target` because it is not needed anymore (bs5 uses `data-bs-toggle` and `data-bs-target`) + mixin managingYourSubscription() .ol-accordions-container .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ1" aria-expanded="false" aria-controls="managingYourSubscriptionQ1") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#managingYourSubscriptionQ1" + data-bs-toggle="collapse" + data-bs-target="#managingYourSubscriptionQ1" + aria-expanded="false" + aria-controls="managingYourSubscriptionQ1" + ) | Can I change plans or cancel later? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -11,7 +21,15 @@ mixin managingYourSubscription() strong Account > Subscription span when logged in to Overleaf. You can change plans, switch between monthly and annual billing options, or cancel to downgrade to the free plan. When canceling, your subscription will continue until the end of the billing period. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ2" aria-expanded="false" aria-controls="managingYourSubscriptionQ2") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#managingYourSubscriptionQ2" + data-bs-toggle="collapse" + data-bs-target="#managingYourSubscriptionQ2" + aria-expanded="false" + aria-controls="managingYourSubscriptionQ2" + ) | If I change or cancel my Overleaf plan, will I lose my projects? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -19,7 +37,15 @@ mixin managingYourSubscription() .custom-accordion-body | No. Changing or canceling your plan won’t affect your projects, the only change will be to the features available to you. You can see which features are available only on paid plans in the comparison table. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ3" aria-expanded="false" aria-controls="managingYourSubscriptionQ3") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#managingYourSubscriptionQ3" + data-bs-toggle="collapse" + data-bs-target="#managingYourSubscriptionQ3" + aria-expanded="false" + aria-controls="managingYourSubscriptionQ3" + ) | Can I pay by invoice or purchase order? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -27,7 +53,15 @@ mixin managingYourSubscription() .custom-accordion-body | This is possible when you’re purchasing a group subscription for five or more people, or a site license. For individual subscriptions, we can only accept payment online via credit card, debit card, or PayPal. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#managingYourSubscriptionQ4" aria-expanded="false" aria-controls="managingYourSubscriptionQ4") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#managingYourSubscriptionQ4" + data-bs-toggle="collapse" + data-bs-target="#managingYourSubscriptionQ4" + aria-expanded="false" + aria-controls="managingYourSubscriptionQ4" + ) | How do I view/update the credit card being charged for my subscription? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -51,7 +85,15 @@ mixin managingYourSubscription() mixin overleafIndividualPlans() .ol-accordions-container .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ1" aria-expanded="false" aria-controls="overleafIndividualPlansQ1") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafIndividualPlansQ1" + data-bs-toggle="collapse" + data-bs-target="#overleafIndividualPlansQ1" + aria-expanded="false" + aria-controls="overleafIndividualPlansQ1" + ) | How does the free trial work? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -71,7 +113,15 @@ mixin overleafIndividualPlans() span when logged in to Overleaf (the trial will continue for the full 7 days). .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ2" aria-expanded="false" aria-controls="overleafIndividualPlansQ2") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafIndividualPlansQ2" + data-bs-toggle="collapse" + data-bs-target="#overleafIndividualPlansQ2" + aria-expanded="false" + aria-controls="overleafIndividualPlansQ2" + ) | What’s a collaborator on an Overleaf individual subscription? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -80,7 +130,15 @@ mixin overleafIndividualPlans() | A collaborator is someone you invite to work with you on a project. So, for example, on our Standard plan you can have up to 10 people collaborating with you on any given project. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ3" aria-expanded="false" aria-controls="overleafIndividualPlansQ3") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafIndividualPlansQ3" + data-bs-toggle="collapse" + data-bs-target="#overleafIndividualPlansQ3" + aria-expanded="false" + aria-controls="overleafIndividualPlansQ3" + ) | The individual Standard plan has 10 project collaborators, does it mean that 10 people will be upgraded? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -90,7 +148,15 @@ mixin overleafIndividualPlans() strong only span for the project(s) they’re working on with you. If your collaborators want access to those features on their own projects, they will need to purchase their own subscription. (If you work with the same people regularly, you might find a group subscription more cost effective.) .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ4" aria-expanded="false" aria-controls="overleafIndividualPlansQ4") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafIndividualPlansQ4" + data-bs-toggle="collapse" + data-bs-target="#overleafIndividualPlansQ4" + aria-expanded="false" + aria-controls="overleafIndividualPlansQ4" + ) | Do collaborators also have access to the editing and collaboration features I’ve paid for? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -100,7 +166,15 @@ mixin overleafIndividualPlans() strong only span for the project(s) they’re working on with you. If your collaborators want access to those features on their own projects, they will need to purchase their own subscription. (If you work with the same people regularly, you might find a group subscription more cost effective.) .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ5" aria-expanded="false" aria-controls="overleafIndividualPlansQ5") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafIndividualPlansQ5" + data-bs-toggle="collapse" + data-bs-target="#overleafIndividualPlansQ5" + aria-expanded="false" + aria-controls="overleafIndividualPlansQ5" + ) | Can I purchase an individual plan on behalf of someone else? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -108,7 +182,15 @@ mixin overleafIndividualPlans() .custom-accordion-body | Individual subscriptions must be purchased by the account that will be the end user. If you want to purchase a plan for someone else, you’ll need to provide them with relevant payment details to enable them to make the purchase. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ6" aria-expanded="false" aria-controls="overleafIndividualPlansQ6") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafIndividualPlansQ6" + data-bs-toggle="collapse" + data-bs-target="#overleafIndividualPlansQ6" + aria-expanded="false" + aria-controls="overleafIndividualPlansQ6" + ) | Who is eligible for the Student plan? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -116,7 +198,15 @@ mixin overleafIndividualPlans() .custom-accordion-body | As the name suggests, the Student plan is only for students at educational institutions. This includes graduate students. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafIndividualPlansQ7" aria-expanded="false" aria-controls="overleafIndividualPlansQ7") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafIndividualPlansQ7" + data-bs-toggle="collapse" + data-bs-target="#overleafIndividualPlansQ7" + aria-expanded="false" + aria-controls="overleafIndividualPlansQ7" + ) | Can I transfer an individual subscription to someone else? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -131,7 +221,15 @@ mixin overleafIndividualPlans() mixin overleafGroupPlans() .ol-accordions-container .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ1" aria-expanded="false" aria-controls="overleafGroupPlansQ1") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafGroupPlansQ1" + data-bs-toggle="collapse" + data-bs-target="#overleafGroupPlansQ1" + aria-expanded="false" + aria-controls="overleafGroupPlansQ1" + ) | What’s the difference between users and collaborators on an Overleaf group subscription? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -140,7 +238,15 @@ mixin overleafGroupPlans() div On any of our group plans, the number of users refers to the number of people you can invite to join your group. All of these people will have access to the plan’s paid-for features across all their projects, such as real-time track changes and document history. div.mt-2 Collaborators are people that your group users may invite to work with them on their projects. So, for example, if you have the Group Standard plan, the users in your group can invite up to 10 people to work with them on a project. And if you have the Group Professional plan, your users can invite as many people to work with them as they want. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ2" aria-expanded="false" aria-controls="overleafGroupPlansQ2") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafGroupPlansQ2" + data-bs-toggle="collapse" + data-bs-target="#overleafGroupPlansQ2" + aria-expanded="false" + aria-controls="overleafGroupPlansQ2" + ) | What is the benefit of purchasing an Overleaf Group plan? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -158,7 +264,15 @@ mixin overleafGroupPlans() span Sales team | . .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ3" aria-expanded="false" aria-controls="overleafGroupPlansQ3") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafGroupPlansQ3" + data-bs-toggle="collapse" + data-bs-target="#overleafGroupPlansQ3" + aria-expanded="false" + aria-controls="overleafGroupPlansQ3" + ) | Who is eligible for the educational discount? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -166,7 +280,15 @@ mixin overleafGroupPlans() .custom-accordion-body | The educational discount for group subscriptions is for students or faculty who are using Overleaf primarily for teaching. .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ4" aria-expanded="false" aria-controls="overleafGroupPlansQ4") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafGroupPlansQ4" + data-bs-toggle="collapse" + data-bs-target="#overleafGroupPlansQ4" + aria-expanded="false" + aria-controls="overleafGroupPlansQ4" + ) | How do I add more licenses to my group subscription, and what will it cost? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down @@ -207,7 +329,15 @@ mixin overleafGroupPlans() span contact the Sales team | . .custom-accordion-item - button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ5" aria-expanded="false" aria-controls="overleafGroupPlansQ5") + button.custom-accordion-header.collapsed( + type="button" + data-toggle="collapse" + data-target="#overleafGroupPlansQ5" + data-bs-toggle="collapse" + data-bs-target="#overleafGroupPlansQ5" + aria-expanded="false" + aria-controls="overleafGroupPlansQ5" + ) | How do I upgrade my plan from Group Standard to Group Professional? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down diff --git a/services/web/app/views/subscriptions/preview-change.pug b/services/web/app/views/subscriptions/preview-change.pug index ab70d2d6b6..663bbe30d2 100644 --- a/services/web/app/views/subscriptions/preview-change.pug +++ b/services/web/app/views/subscriptions/preview-change.pug @@ -5,6 +5,7 @@ block entrypointVar block append meta meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview) + meta(name="ol-purchaseReferrer" data-type="string" content=purchaseReferrer) block content main.content.content-alt#main-content diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 57eef44c27..4f939a41ca 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -22,6 +22,7 @@ block append meta meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {}) meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed()) meta(name="ol-user" data-type="json" content=user) + meta(name="ol-labsExperiments" data-type="json" content=labsExperiments) meta(name="ol-dropbox" data-type="json" content=dropbox) meta(name="ol-github" data-type="json" content=github) meta(name="ol-projectSyncSuccessMessage", content=projectSyncSuccessMessage) diff --git a/services/web/babel.config.json b/services/web/babel.config.json index 9bb4c73829..0eee4a9880 100644 --- a/services/web/babel.config.json +++ b/services/web/babel.config.json @@ -6,7 +6,7 @@ { "useBuiltIns": "usage", // This version must be aligned with the `core-js` version in `package.json` - "corejs": { "version": "3.38" }, + "corejs": { "version": "3.41" }, "exclude": [ // Exclude Array.prototype.push polyfill, as it's not needed and affects performance in Chrome "es.array.push", diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 1f86da66a0..be567bf13e 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -242,6 +242,9 @@ module.exports = { submissionBackendClass: process.env.CLSI_SUBMISSION_BACKEND_CLASS || 'n2d', }, + clsiCache: { + instances: JSON.parse(process.env.CLSI_CACHE_INSTANCES || '[]'), + }, project_history: { sendProjectStructureOps: true, url: `http://${process.env.PROJECT_HISTORY_HOST || '127.0.0.1'}:3054`, diff --git a/services/web/cypress/support/ct/commands/index.ts b/services/web/cypress/support/ct/commands/index.ts index 4ad9c7940c..f5528bc1bb 100644 --- a/services/web/cypress/support/ct/commands/index.ts +++ b/services/web/cypress/support/ct/commands/index.ts @@ -1,4 +1,4 @@ -import { mount } from 'cypress/react' +import { mount } from 'cypress/react18' // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-namespace declare global { diff --git a/services/web/cypress/support/ct/window.ts b/services/web/cypress/support/ct/window.ts index 3f415cf3c5..ae2a194bc1 100644 --- a/services/web/cypress/support/ct/window.ts +++ b/services/web/cypress/support/ct/window.ts @@ -10,7 +10,6 @@ export function resetMeta() { hasLinkedProjectOutputFileFeature: true, hasLinkUrlFeature: true, }) - window.metaAttributesCache.set('ol-bootstrapVersion', 5) } // Populate meta for top-level access in modules on import diff --git a/services/web/cypress/support/shared/commands/compile.ts b/services/web/cypress/support/shared/commands/compile.ts index 90567d8e74..9f7273c403 100644 --- a/services/web/cypress/support/shared/commands/compile.ts +++ b/services/web/cypress/support/shared/commands/compile.ts @@ -43,8 +43,42 @@ const outputFiles = () => { ] } -export const interceptCompile = (prefix = 'compile', times = 1) => { - cy.intercept( +const compileFromCacheResponse = () => { + return { + fromCache: true, + status: 'success', + clsiServerId: 'foo', + compileGroup: 'priority', + pdfDownloadDomain: 'https://clsi.test-overleaf.com', + outputFiles: outputFiles(), + options: { + rootResourcePath: 'main.tex', + imageName: 'texlive-full:2024.1', + compiler: 'pdflatex', + stopOnFirstError: false, + draft: false, + }, + } +} + +export const interceptCompileFromCacheRequest = ({ + times, + promise, +}: { + times: number + promise: Promise +}) => { + return cy.intercept( + { path: '/project/*/output/cached/output.overleaf.json', times }, + async req => { + await promise + req.reply({ body: compileFromCacheResponse() }) + } + ) +} + +export const interceptCompileRequest = ({ times = 1 } = {}) => { + return cy.intercept( { method: 'POST', pathname: '/project/*/compile', times }, { body: { @@ -55,11 +89,48 @@ export const interceptCompile = (prefix = 'compile', times = 1) => { outputFiles: outputFiles(), }, } - ).as(`${prefix}`) + ) +} + +export const interceptCompile = ({ + prefix = 'compile', + times = 1, + cached = false, + regular = true, + outputPDFFixture = 'output.pdf', +} = {}) => { + if (cached) { + cy.intercept( + { path: '/project/*/output/cached/output.overleaf.json', times }, + { body: compileFromCacheResponse() } + ).as(`${prefix}-cached`) + } else { + cy.intercept( + { pathname: '/project/*/output/cached/output.overleaf.json', times }, + { statusCode: 404 } + ).as(`${prefix}-cached`) + } + + if (regular) { + interceptCompileRequest({ times }).as(`${prefix}`) + } else { + cy.intercept( + { method: 'POST', pathname: '/project/*/compile', times }, + { + body: { + status: 'unavailable', + clsiServerId: 'foo', + compileGroup: 'priority', + pdfDownloadDomain: 'https://clsi.test-overleaf.com', + outputFiles: [], + }, + } + ).as(`${prefix}`) + } cy.intercept( { pathname: '/build/*/output.pdf', times }, - { fixture: 'build/output.pdf,null' } + { fixture: `build/${outputPDFFixture},null` } ).as(`${prefix}-pdf`) cy.intercept( @@ -73,12 +144,36 @@ export const interceptCompile = (prefix = 'compile', times = 1) => { ).as(`${prefix}-blg`) } -export const waitForCompile = ({ prefix = 'compile', pdf = false } = {}) => { - cy.wait(`@${prefix}`) +export const waitForCompile = ({ + prefix = 'compile', + pdf = false, + cached = false, + regular = true, +} = {}) => { + if (cached) { + cy.wait(`@${prefix}-cached`) + } + if (regular) { + cy.wait(`@${prefix}`) + } + return waitForCompileOutput({ prefix, pdf, cached }) +} + +export const waitForCompileOutput = ({ + prefix = 'compile', + pdf = false, + cached = false, +} = {}) => { cy.wait(`@${prefix}-log`) + .its('request.query.clsiserverid') + .should('eq', cached ? 'cache' : 'foo') // straight from cache if cached cy.wait(`@${prefix}-blg`) + .its('request.query.clsiserverid') + .should('eq', cached ? 'cache' : 'foo') // straight from cache if cached if (pdf) { cy.wait(`@${prefix}-pdf`) + .its('request.query.clsiserverid') + .should('eq', 'foo') // always from VM first } return cy.wrap(null) } diff --git a/services/web/cypress/support/shared/commands/index.ts b/services/web/cypress/support/shared/commands/index.ts index cd39f8f5d1..bb55fdddac 100644 --- a/services/web/cypress/support/shared/commands/index.ts +++ b/services/web/cypress/support/shared/commands/index.ts @@ -1,8 +1,10 @@ import '@testing-library/cypress/add-commands' import { interceptCompile, + interceptCompileFromCacheRequest, waitForCompile, interceptDeferredCompile, + interceptCompileRequest, } from './compile' import { interceptEvents } from './events' import { interceptAsync } from './intercept-async' @@ -21,6 +23,8 @@ declare global { interface Chainable { interceptAsync: typeof interceptAsync interceptCompile: typeof interceptCompile + interceptCompileRequest: typeof interceptCompileRequest + interceptCompileFromCacheRequest: typeof interceptCompileFromCacheRequest interceptEvents: typeof interceptEvents interceptMetadata: typeof interceptMetadata waitForCompile: typeof waitForCompile @@ -36,6 +40,11 @@ declare global { Cypress.Commands.add('interceptAsync', interceptAsync) Cypress.Commands.add('interceptCompile', interceptCompile) +Cypress.Commands.add('interceptCompileRequest', interceptCompileRequest) +Cypress.Commands.add( + 'interceptCompileFromCacheRequest', + interceptCompileFromCacheRequest +) Cypress.Commands.add('interceptEvents', interceptEvents) Cypress.Commands.add('interceptMetadata', interceptMetadata) Cypress.Commands.add('waitForCompile', waitForCompile) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index f0c492800c..7df8553f55 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -46,6 +46,7 @@ "access_denied": "", "access_edit_your_projects": "", "access_levels_changed": "", + "access_your_favourite_features_faster_with_our_new_streamlined_editor": "", "account_billed_manually": "", "account_has_been_link_to_institution_account": "", "account_has_past_due_invoice_change_plan_warning": "", @@ -60,6 +61,7 @@ "add_add_on_to_your_plan": "", "add_additional_certificate": "", "add_affiliation": "", + "add_ai_assist": "", "add_another_address_line": "", "add_another_email": "", "add_another_token": "", @@ -73,21 +75,20 @@ "add_error_assist_annual_to_your_projects": "", "add_error_assist_to_your_projects": "", "add_files": "", - "add_more_editors": "", + "add_more_collaborators": "", "add_more_licenses_to_my_plan": "", "add_more_managers": "", "add_new_email": "", "add_on": "", "add_ons": "", "add_or_remove_project_from_tag": "", - "add_overleaf_assist": "", "add_overleaf_assist_to_your_group_subscription": "", "add_overleaf_assist_to_your_institution": "", - "add_overleaf_assist_to_your_plan": "", "add_people": "", "add_role_and_department": "", "add_to_dictionary": "", "add_to_tag": "", + "add_unlimited_ai_to_your_overleaf_plan": "", "add_your_comment_here": "", "add_your_first_group_member_now": "", "added_by_on": "", @@ -101,7 +102,9 @@ "after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "", "aggregate_changed": "", "aggregate_to": "", + "agree": "", "agree_with_the_terms": "", + "ai_assist_in_overleaf_is_included_via_writefull": "", "ai_assistance_to_help_you": "", "ai_based_language_tools": "", "ai_can_make_mistakes": "", @@ -122,6 +125,7 @@ "all_projects_will_be_transferred_immediately": "", "all_these_experiments_are_available_exclusively": "", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "", + "already_have_a_papers_account": "", "already_subscribed_try_refreshing_the_page": "", "an_email_has_already_been_sent_to": "", "an_error_occured_while_restoring_project": "", @@ -180,7 +184,9 @@ "blank_project": "", "blocked_filename": "", "blog": "", + "bold": "", "browser": "", + "bullet_list": "", "buy_licenses": "", "buy_more_licenses": "", "by_subscribing_you_agree_to_our_terms_of_service": "", @@ -241,6 +247,7 @@ "choose_from_group_members": "", "choose_how_you_search_your_references": "", "choose_which_experiments": "", + "citation": "", "clear_cached_files": "", "clear_search": "", "click_here_to_view_sl_in_lng": "", @@ -254,8 +261,6 @@ "code_check_failed": "", "code_check_failed_explanation": "", "code_editor": "", - "code_editor_tooltip_message": "", - "code_editor_tooltip_title": "", "collaborate_online_and_offline": "", "collaborator_chat": "", "collabs_per_proj": "", @@ -334,6 +339,7 @@ "create_project_in_github": "", "created": "", "created_at": "", + "cross_reference": "", "current_file": "", "current_password": "", "currently_seeing_only_24_hrs_history": "", @@ -344,9 +350,9 @@ "customize_your_group_subscription": "", "customizing_figures": "", "customizing_tables": "", - "dark_mode": "", "date_and_owner": "", "dealing_with_errors": "", + "decrease_indent": "", "delete": "", "delete_account": "", "delete_account_confirmation_label": "", @@ -377,6 +383,7 @@ "deleted_by_id": "", "deleted_by_ip": "", "deleted_by_on": "", + "deleted_user": "", "deleting": "", "demonstrating_git_integration": "", "demonstrating_track_changes_feature": "", @@ -395,12 +402,15 @@ "disable_stop_on_first_error": "", "disabled": "", "disabling": "", + "disagree": "", "disconnected": "", "discount": "", "discount_of": "", "discover_the_fastest_way_to_search_and_cite": "", "dismiss_error_popup": "", + "display": "", "display_deleted_user": "", + "display_math": "", "do_you_need_edit_access": "", "do_you_want_to_change_your_primary_email_address_to": "", "do_you_want_to_overwrite_it": "", @@ -464,7 +474,7 @@ "edit_your_custom_dictionary": "", "editing": "", "editing_captions": "", - "editing_tools_to_paraphrase_change_style_and_more": "", + "editing_tools": "", "editor": "", "editor_and_pdf": "", "editor_disconected_click_to_reconnect": "", @@ -514,7 +524,7 @@ "equation_preview": "", "error": "", "error_assist": "", - "error_assist_to_help_fixing_latex_errors": "", + "error_log": "", "error_opening_document": "", "error_opening_document_detail": "", "error_performing_request": "", @@ -537,6 +547,7 @@ "fast": "", "fast_draft": "", "features_like_track_changes": "", + "figure": "", "file": "", "file_action_created": "", "file_action_deleted": "", @@ -557,6 +568,7 @@ "files_cannot_include_invalid_characters": "", "files_selected": "", "filter_projects": "", + "find": "", "find_out_more": "", "find_out_more_about_institution_login": "", "find_out_more_about_the_file_outline": "", @@ -599,6 +611,7 @@ "full_doc_history": "", "full_width": "", "future_payments": "", + "generate_from_text_or_image": "", "generate_token": "", "generic_if_problem_continues_contact_us": "", "generic_linked_file_compile_error": "", @@ -661,6 +674,9 @@ "go_to_pdf_location_in_code": "", "go_to_settings": "", "go_to_subscriptions": "", + "go_to_writefull": "", + "good_news_you_already_purchased_this_add_on": "", + "good_news_you_are_already_receiving_this_add_on_via_writefull": "", "group_admin": "", "group_invitations": "", "group_invite_has_been_sent_to_email": "", @@ -744,6 +760,7 @@ "how_we_use_your_data": "", "how_we_use_your_data_explanation": "", "i_confirm_am_student": "", + "i_want_to_add_a_po_number": "", "i_want_to_stay": "", "id": "", "identify_errors_with_your_compile": "", @@ -769,6 +786,7 @@ "imported_from_zotero_at_date": "", "importing": "", "importing_and_merging_changes_in_github": "", + "improved_dark_mode": "", "in_order_to_match_institutional_metadata_2": "", "in_order_to_match_institutional_metadata_associated": "", "include_caption": "", @@ -776,7 +794,11 @@ "include_results_from_your_reference_manager": "", "include_results_from_your_x_account": "", "include_the_error_message_and_ai_response": "", + "included_as_part_of_your_writefull_subscription": "", + "increase_indent": "", "increased_compile_timeout": "", + "inline": "", + "inline_math": "", "inr_discount_modal_info": "", "inr_discount_modal_title": "", "insert": "", @@ -827,6 +849,7 @@ "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "", "it_looks_like_your_account_is_billed_manually": "", "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "", + "italics": "", "join_beta_program": "", "join_now": "", "join_overleaf_labs": "", @@ -846,7 +869,7 @@ "labels_help_you_to_reference_your_tables": "", "labs": "", "language": "", - "language_suggestions_for_texts_in_any_language": "", + "language_suggestions": "", "large_or_high-resolution_images_taking_too_long": "", "large_or_high_resolution_images_taking_too_long_to_process": "", "last_active": "", @@ -889,11 +912,9 @@ "licenses": "", "limited_document_history": "", "limited_offer": "", + "limited_to_n_collaborators_per_project": "", + "limited_to_n_collaborators_per_project_plural": "", "limited_to_n_editors": "", - "limited_to_n_editors_or_reviewers": "", - "limited_to_n_editors_or_reviewers_per_project": "", - "limited_to_n_editors_or_reviewers_per_project_plural": "", - "limited_to_n_editors_or_reviewers_plural": "", "limited_to_n_editors_per_project": "", "limited_to_n_editors_per_project_plural": "", "limited_to_n_editors_plural": "", @@ -964,6 +985,7 @@ "manage_publisher_managers": "", "manage_sessions": "", "manage_subscription": "", + "manage_your_ai_assist_add_on": "", "managed": "", "managed_user_accounts": "", "managed_user_invite_has_been_sent_to_email": "", @@ -974,6 +996,7 @@ "managers_management": "", "managing_your_subscription": "", "marked_as_resolved": "", + "math": "", "math_display": "", "math_inline": "", "maximum_files_uploaded_together": "", @@ -1001,9 +1024,11 @@ "month_plural": "", "more": "", "more_actions": "", + "more_changes_based_on_your_feedback": "", "more_collabs_per_project": "", "more_comments": "", "more_compile_time": "", + "more_editor_toolbar_item": "", "more_info": "", "more_options": "", "more_options_for_border_settings_coming_soon": "", @@ -1024,11 +1049,16 @@ "need_more_than_x_licenses": "", "need_to_add_new_primary_before_remove": "", "need_to_leave": "", + "neither_agree_nor_disagree": "", "new_compile_domain_notice": "", "new_file": "", "new_folder": "", "new_font_open_dyslexic": "", + "new_look_and_feel": "", + "new_look_and_placement_of_the_settings": "", "new_name": "", + "new_navigation_introducing_left_hand_side_rail_and_top_menus": "", + "new_overleaf_editor": "", "new_password": "", "new_project": "", "new_subscription_will_be_billed_immediately": "", @@ -1074,7 +1104,9 @@ "notification_personal_and_group_subscriptions": "", "notification_project_invite_accepted_message": "", "notification_project_invite_message": "", + "now_you_can_search_your_whole_project_not_just_this_file": "", "number_of_users": "", + "numbered_list": "", "oauth_orcid_description": "", "of": "", "off": "", @@ -1102,7 +1134,6 @@ "optional": "", "or": "", "organization_name": "", - "organize_projects": "", "organize_tags": "", "other": "", "other_causes_of_compile_timeouts": "", @@ -1115,10 +1146,11 @@ "output_file": "", "overall_theme": "", "overleaf": "", - "overleaf_assist_streamline_your_workflow": "", "overleaf_history_system": "", + "overleaf_is_easy_to_use": "", "overleaf_labs": "", "overleaf_logo": "", + "overleafs_functionality_meets_my_needs": "", "overview": "", "overwrite": "", "overwriting_the_original_folder": "", @@ -1137,6 +1169,7 @@ "papers_sync_description": "", "papers_upgrade_prompt_content": "", "papers_upgrade_prompt_title": "", + "paragraph_styles": "", "partial_outline_warning": "", "password": "", "password_managed_externally": "", @@ -1156,6 +1189,7 @@ "pdf_in_separate_tab": "", "pdf_only": "", "pdf_only_hide_editor": "", + "pdf_preview": "", "pdf_preview_error": "", "pdf_rendering_error": "", "pdf_unavailable_for_download": "", @@ -1166,13 +1200,14 @@ "pending_invite": "", "per_license": "", "per_month": "", + "per_month_billed_annually": "", "percent_is_the_percentage_of_the_line_width": "", "permanently_disables_the_preview": "", "personal_library": "", "pick_up_where_you_left_off": "", "plan": "", "plan_tooltip": "", - "please_ask_the_project_owner_to_upgrade_more_editors": "", + "please_ask_the_project_owner_to_upgrade_more_collaborators": "", "please_ask_the_project_owner_to_upgrade_to_track_changes": "", "please_change_primary_to_remove": "", "please_check_your_inbox_to_confirm": "", @@ -1187,6 +1222,7 @@ "please_link_before_making_primary": "", "please_provide_a_message": "", "please_provide_a_subject": "", + "please_provide_a_valid_email_address": "", "please_reconfirm_institutional_email": "", "please_reconfirm_your_affiliation_before_making_this_primary": "", "please_refresh": "", @@ -1199,6 +1235,7 @@ "plus_additional_collaborators_document_history_track_changes_and_more": "", "plus_more": "", "plus_x_additional_licenses_for_a_total_of_y_licenses": "", + "po_number": "", "postal_code": "", "premium": "", "premium_feature": "", @@ -1300,6 +1337,7 @@ "recurly_email_updated": "", "redirect_to_editor": "", "redirect_url": "", + "redo": "", "reduce_costs_group_licenses": "", "reference_error_relink_hint": "", "reference_manager_searched_groups": "", @@ -1378,7 +1416,8 @@ "reverse_x_sort_order": "", "revert_pending_plan_change": "", "review": "", - "review_panel_comments_and_track_changes": "", + "review_panel": "", + "review_panel_and_error_logs_moved_to_the_left": "", "review_your_peers_work": "", "reviewer": "", "reviewer_dropbox_sync_message": "", @@ -1408,7 +1447,6 @@ "search": "", "search_all_project_files": "", "search_bib_files": "", - "search_by_author_journal_title_and_more_link_to_zotero_mendeley": "", "search_by_author_journal_title_and_more_link_to_zotero_mendeley_papers": "", "search_by_citekey_author_year_title": "", "search_command_find": "", @@ -1466,6 +1504,7 @@ "select_image_from_project_files": "", "select_project": "", "select_projects": "", + "select_size": "", "select_tag": "", "select_user": "", "selected": "", @@ -1488,7 +1527,6 @@ "set_up_single_sign_on": "", "set_up_sso": "", "settings": "", - "settings_for_git_github_and_dropbox_integrations": "", "setup_another_account_under_a_personal_email_address": "", "share": "", "share_project": "", @@ -1497,6 +1535,7 @@ "shortcut_to_open_advanced_reference_search": "", "show_all_projects": "", "show_document_preamble": "", + "show_equation_preview": "", "show_file_tree": "", "show_hotkeys": "", "show_in_code": "", @@ -1531,6 +1570,7 @@ "sorry_there_was_an_issue_adding_x_users_to_your_subscription": "", "sorry_there_was_an_issue_upgrading_your_subscription": "", "sorry_you_can_only_change_to_group_from_trial_via_support": "", + "sorry_you_can_only_change_to_group_via_support": "", "sorry_your_table_cant_be_displayed_at_the_moment": "", "sort_by": "", "sort_by_x": "", @@ -1602,6 +1642,8 @@ "stop_on_validation_error": "", "store_your_work": "", "stretch_width_to_text": "", + "strongly_agree": "", + "strongly_disagree": "", "student": "", "student_disclaimer": "", "subject": "", @@ -1637,6 +1679,7 @@ "switch_to_old_editor": "", "switch_to_pdf": "", "switch_to_standard_plan": "", + "symbol": "", "symbol_palette": "", "sync": "", "sync_dropbox_github": "", @@ -1647,6 +1690,7 @@ "syntax_validation": "", "tab_connecting": "", "tab_no_longer_connected": "", + "table": "", "table_generator": "", "tag_color": "", "tag_name_cannot_exceed_characters": "", @@ -1667,7 +1711,8 @@ "test_configuration": "", "test_configuration_successful": "", "tex_live_version": "", - "texgpt_for_help_writing_latex": "", + "texgpt": "", + "thank_you": "", "thank_you_exclamation": "", "thank_you_for_your_feedback": "", "thanks_for_being_part_of_this_labs_experiment_your_feedback_will_help_us_make_the_new_editor_the_best_yet": "", @@ -1711,10 +1756,10 @@ "this_is_a_labs_experiment_for_the_new_overleaf_editor_some_features_are_still_in_progress": "", "this_is_a_new_feature": "", "this_is_the_file_that_references_pulled_from_your_reference_manager_will_be_added_to": "", - "this_project_already_has_maximum_editors": "", + "this_project_already_has_maximum_collaborators": "", "this_project_contains_a_file_called_output": "", + "this_project_exceeded_collaborator_limit": "", "this_project_exceeded_compile_timeout_limit_on_free_plan": "", - "this_project_exceeded_editor_limit": "", "this_project_has_more_than_max_collabs": "", "this_project_is_public": "", "this_project_is_public_read_only": "", @@ -1769,6 +1814,8 @@ "toolbar_editor": "", "toolbar_format_bold": "", "toolbar_format_italic": "", + "toolbar_generate_math": "", + "toolbar_generate_table": "", "toolbar_increase_indent": "", "toolbar_insert_citation": "", "toolbar_insert_cross_reference": "", @@ -1778,6 +1825,7 @@ "toolbar_insert_link": "", "toolbar_insert_math": "", "toolbar_insert_math_and_symbols": "", + "toolbar_insert_math_lowercase": "", "toolbar_insert_misc": "", "toolbar_insert_table": "", "toolbar_list_indentation": "", @@ -1836,6 +1884,7 @@ "try_for_free": "", "try_it_for_free": "", "try_now": "", + "try_papers_for_free": "", "try_premium_for_free": "", "try_recompile_project_or_troubleshoot": "", "try_relinking_provider": "", @@ -1852,6 +1901,7 @@ "unconfirmed": "", "undelete": "", "understanding_labels": "", + "undo": "", "unfold_line": "", "unique_identifier_attribute": "", "university": "", @@ -1900,7 +1950,7 @@ "upgrade_now": "", "upgrade_plan": "", "upgrade_summary": "", - "upgrade_to_add_more_editors_and_access_collaboration_features": "", + "upgrade_to_add_more_collaborators_and_access_collaboration_features": "", "upgrade_to_get_feature": "", "upgrade_to_review": "", "upgrade_to_track_changes": "", @@ -1964,6 +2014,7 @@ "view_only_downgraded": "", "view_only_reviewer_downgraded": "", "view_options": "", + "view_payment_portal": "", "view_pdf": "", "view_your_invoices": "", "viewer": "", @@ -2013,6 +2064,7 @@ "work_offline_pull_to_overleaf": "", "work_with_non_overleaf_users": "", "work_with_other_github_users": "", + "write_faster_smarter_with_overleaf_and_writefull_ai_tools": "", "writefull": "", "writefull_loading_error_body": "", "writefull_loading_error_title": "", @@ -2045,9 +2097,10 @@ "you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "", "you_can_now_enable_sso": "", "you_can_now_log_in_sso": "", + "you_can_now_sync_your_papers_library_directly_with_your_overleaf_projects": "", "you_can_request_a_maximum_of_limit_fixes_per_day": "", - "you_can_select_or_invite": "", - "you_can_select_or_invite_plural": "", + "you_can_select_or_invite_collaborator": "", + "you_can_select_or_invite_collaborator_plural": "", "you_can_still_use_your_premium_features": "", "you_cant_add_or_change_password_due_to_sso": "", "you_cant_join_this_group_subscription": "", @@ -2079,6 +2132,7 @@ "your_current_plan_gives_you": "", "your_current_plan_supports_up_to_x_licenses": "", "your_current_project_will_revert_to_the_version_from_time": "", + "your_feedback_matters_answer_two_quick_questions": "", "your_git_access_info": "", "your_git_access_info_bullet_1": "", "your_git_access_info_bullet_2": "", @@ -2095,8 +2149,8 @@ "your_plan_is_limited_to_n_editors": "", "your_plan_is_limited_to_n_editors_plural": "", "your_premium_plan_is_paused": "", + "your_project_exceeded_collaborator_limit": "", "your_project_exceeded_compile_timeout_limit_on_free_plan": "", - "your_project_exceeded_editor_limit": "", "your_project_near_compile_timeout_limit": "", "your_project_need_more_time_to_compile": "", "your_projects": "", @@ -2117,7 +2171,7 @@ "youre_signed_in_as_logout": "", "youve_added_more_licenses": "", "youve_added_x_more_licenses_to_your_subscription_invite_people": "", - "youve_lost_edit_access": "", + "youve_lost_collaboration_access": "", "youve_paused_your_subscription": "", "youve_unlinked_all_users": "", "youve_upgraded_your_plan": "", diff --git a/services/web/frontend/js/features/chat/components/chat-pane.tsx b/services/web/frontend/js/features/chat/components/chat-pane.tsx index d51037762e..1ef4d46a31 100644 --- a/services/web/frontend/js/features/chat/components/chat-pane.tsx +++ b/services/web/frontend/js/features/chat/components/chat-pane.tsx @@ -114,4 +114,4 @@ function Placeholder() { ) } -export default withErrorBoundary(ChatPane, ChatFallbackError) +export default withErrorBoundary(ChatPane, () => ) diff --git a/services/web/frontend/js/features/chat/context/chat-context.tsx b/services/web/frontend/js/features/chat/context/chat-context.tsx index d2b078138d..d86171a451 100644 --- a/services/web/frontend/js/features/chat/context/chat-context.tsx +++ b/services/web/frontend/js/features/chat/context/chat-context.tsx @@ -190,7 +190,7 @@ export const ChatContext = createContext< | undefined >(undefined) -export const ChatProvider: FC = ({ children }) => { +export const ChatProvider: FC = ({ children }) => { const chatEnabled = getMeta('ol-chatEnabled') const clientId = useRef() @@ -283,7 +283,7 @@ export const ChatProvider: FC = ({ children }) => { ]) const sendMessage = useCallback( - content => { + (content: string) => { if (!chatEnabled) { debugConsole.warn(`chat is disabled, won't send message`) return diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx index e38bbc18ae..38795e145c 100644 --- a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx +++ b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx @@ -33,7 +33,7 @@ export default function DictionaryModalContent({ const { isError, runAsync } = useAsync() const handleRemove = useCallback( - word => { + (word: string) => { runAsync(postJSON('/spelling/unlearn', { body: { word } })) .then(() => { setLearnedWords(prevLearnedWords => { diff --git a/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx b/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx index 3e05c907d6..7832939541 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/actions-word-count.tsx @@ -1,15 +1,10 @@ import { useState, useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import WordCountModal from '../../word-count-modal/components/word-count-modal' -import LeftMenuButton from './left-menu-button' import * as eventTracking from '../../../infrastructure/event-tracking' -import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import { WordCountButton } from '@/features/word-count-modal/components/word-count-button' export default function ActionsWordCount() { const [showModal, setShowModal] = useState(false) - const { pdfUrl } = useCompileContext() - const { t } = useTranslation() const handleShowModal = useCallback(() => { eventTracking.sendMB('left-menu-count') @@ -18,32 +13,7 @@ export default function ActionsWordCount() { return ( <> - {pdfUrl ? ( - - {t('word_count')} - - ) : ( - - {/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */} -
- - {t('word_count')} - -
-
- )} + setShowModal(false)} /> ) diff --git a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx index 8682b056ec..ed2b4e9736 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu-context.tsx @@ -9,17 +9,19 @@ export const EditorLeftMenuContext = createContext< EditorLeftMenuState | undefined >(undefined) -export const EditorLeftMenuProvider: FC = ({ children }) => { +export const EditorLeftMenuProvider: FC = ({ + children, +}) => { const [value, setValue] = useState(() => ({ settingToFocus: undefined, })) useEventListener( 'ui.focus-setting', - useCallback(event => { + useCallback((event: CustomEvent) => { setValue(value => ({ ...value, - settingToFocus: (event as CustomEvent).detail, + settingToFocus: event.detail, })) }, []) ) diff --git a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx index 20aa62b2d6..e40c4c6872 100644 --- a/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx +++ b/services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx @@ -37,7 +37,9 @@ export const ProjectSettingsContext = createContext< ProjectSettingsContextValue | undefined >(undefined) -export const ProjectSettingsProvider: FC = ({ children }) => { +export const ProjectSettingsProvider: FC = ({ + children, +}) => { const { compiler, setCompiler, diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx index 4b3795f70c..293f66d5b2 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.tsx @@ -56,7 +56,7 @@ const EditorNavigationToolbarRoot = React.memo( }, [chatIsOpen, setChatIsOpen, markMessagesAsRead]) const toggleReviewPanelOpen = useCallback( - event => { + (event: any) => { event.preventDefault() eventTracking.sendMB('navigation-clicked-review', { action: isOpentoString(!reviewPanelOpen), @@ -93,7 +93,7 @@ const EditorNavigationToolbarRoot = React.memo( }, [setLeftMenuShown]) const goToUser = useCallback( - user => { + (user: any) => { if (user.doc && typeof user.row === 'number') { openDoc(user.doc, { gotoLine: user.row + 1 }) } diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx b/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx index 91cc150ff6..6fc3bc80a3 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx @@ -17,7 +17,7 @@ const TryNewEditorButton = () => { onClick={onClick} size="sm" leadingIcon={} - variant="info" + variant="secondary" > {t('try_the_new_editor')} diff --git a/services/web/frontend/js/features/event-tracking/search-events.ts b/services/web/frontend/js/features/event-tracking/search-events.ts index 9829a2024e..cd9ff4b8ba 100644 --- a/services/web/frontend/js/features/event-tracking/search-events.ts +++ b/services/web/frontend/js/features/event-tracking/search-events.ts @@ -4,7 +4,10 @@ type SearchEventSegmentation = { 'search-open': | ({ searchType: 'full-project' - } & ({ method: 'keyboard' } | { method: 'button'; location: 'toolbar' })) + } & ( + | { method: 'keyboard' } + | { method: 'button'; location: 'toolbar' | 'search-form' } + )) | ({ searchType: 'document' mode: 'visual' | 'source' diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx index 5e66614dcd..18b8ee117d 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react' +import React, { useCallback, useEffect, useRef } from 'react' import ReactDOM from 'react-dom' import { Dropdown, @@ -13,30 +13,34 @@ function FileTreeContextMenu() { const { fileTreeReadOnly } = useFileTreeData() const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext() const toggleButtonRef = useRef(null) + const keyboardInputRef = useRef(false) useEffect(() => { if (contextMenuCoords) { toggleButtonRef.current = document.querySelector( '.entity-menu-toggle' ) as HTMLButtonElement | null - focusContextMenu() } }, [contextMenuCoords]) - if (!contextMenuCoords || fileTreeReadOnly) return null + useEffect(() => { + if (contextMenuCoords && keyboardInputRef.current) { + const firstDropdownMenuItem = document.querySelector( + '#dropdown-file-tree-context-menu .dropdown-item:not([disabled])' + ) as HTMLButtonElement | null - // A11y - Move the focus to the context menu when it opens - function focusContextMenu() { - const BS3contextMenu = document.querySelector( - '[aria-labelledby="dropdown-file-tree-context-menu"]' - ) as HTMLElement | null - BS3contextMenu?.focus() - } + if (firstDropdownMenuItem) { + firstDropdownMenuItem.focus() + } + } + }, [contextMenuCoords]) function close() { + if (!contextMenuCoords) return setContextMenuCoords(null) + if (toggleButtonRef.current) { - // A11y - Move the focus back to the toggle button when the context menu closes by pressing the Esc key + // A11y - Focus moves back to the trigger button when the context menu is dismissed toggleButtonRef.current.focus() } } @@ -45,14 +49,33 @@ function FileTreeContextMenu() { if (!wantOpen) close() } - // A11y - Close the context menu when the user presses the Tab key - // Focus should move to the next element in the filetree - function handleKeyDown(event: React.KeyboardEvent) { - if (event.key === 'Tab') { + function handleClose(event: React.KeyboardEvent) { + if (event.key === 'Tab' || event.key === 'Escape') { + event.preventDefault() close() } } + const handleKeyDown = useCallback(() => { + keyboardInputRef.current = true + }, []) + + const handleMouseDown = useCallback(() => { + keyboardInputRef.current = false + }, []) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('mousedown', handleMouseDown) + + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('mousedown', handleMouseDown) + } + }, [handleKeyDown, handleMouseDown]) + + if (!contextMenuCoords || fileTreeReadOnly) return null + return ReactDOM.createPortal(
( - ({ bsRole }, ref) => { - return null - } -) - -FakeDropDownToggle.displayName = 'FakeDropDownToggle' - export default FileTreeContextMenu diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx index 2913c6f9e0..6beecd7e0f 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context.tsx @@ -9,13 +9,15 @@ import { FC } from 'react' // FileTreeActionable: global UI state for actions (rename, delete, etc.) // FileTreeMutable: provides entities mutation operations // FileTreeSelectable: handles selection and multi-selection -const FileTreeContext: FC<{ - refProviders: Record - setRefProviderEnabled: (provider: string, value: boolean) => void - setStartedFreeTrial: (value: boolean) => void - onSelect: () => void - fileTreeContainer?: HTMLDivElement -}> = ({ +const FileTreeContext: FC< + React.PropsWithChildren<{ + refProviders: Record + setRefProviderEnabled: (provider: string, value: boolean) => void + setStartedFreeTrial: (value: boolean) => void + onSelect: () => void + fileTreeContainer?: HTMLDivElement + }> +> = ({ refProviders, setRefProviderEnabled, setStartedFreeTrial, diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx index f8be632986..7afdbbdd55 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx @@ -50,7 +50,7 @@ export default function FileTreeImportFromProject() { // use the basename of a path as the file name const setNameFromPath = useCallback( - path => { + (path: string) => { const filename = path.split('/').pop() if (filename) { diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx index de211c6c07..816aa46fb8 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx @@ -1,7 +1,7 @@ import { useFileTreeSelectable } from '../contexts/file-tree-selectable' import { FC, useCallback } from 'react' -const FileTreeInner: FC = ({ children }) => { +const FileTreeInner: FC = ({ children }) => { const { setIsRootFolderSelected, selectedEntityIds, select } = useFileTreeSelectable() diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx index 6223c49054..6b26de3298 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx @@ -218,7 +218,9 @@ function fileTreeActionableReducer(state: State, action: Action) { } } -export const FileTreeActionableProvider: FC = ({ children }) => { +export const FileTreeActionableProvider: FC = ({ + children, +}) => { const { _id: projectId } = useProjectContext() const { fileTreeReadOnly } = useFileTreeData() const { indexAllReferences } = useReferencesContext() @@ -400,7 +402,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => { }, [fileTreeData, selectedEntityIds]) const finishCreatingEntity = useCallback( - entity => { + (entity: any) => { const error = validateCreate(fileTreeData, parentFolderId, entity) if (error) { return Promise.reject(error) @@ -412,7 +414,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => { ) const finishCreatingFolder = useCallback( - name => { + (name: any) => { dispatch({ type: ACTION_TYPES.CREATING_FOLDER }) return finishCreatingEntity({ endpoint: 'folder', name }) .then(() => { @@ -425,7 +427,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => { [finishCreatingEntity] ) - const startCreatingFile = useCallback(newFileCreateMode => { + const startCreatingFile = useCallback((newFileCreateMode: any) => { dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode }) }, []) @@ -438,7 +440,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => { }, [startCreatingFile]) const finishCreatingDocOrFile = useCallback( - entity => { + (entity: any) => { dispatch({ type: ACTION_TYPES.CREATING_FILE }) return finishCreatingEntity(entity) @@ -453,7 +455,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => { ) const finishCreatingDoc = useCallback( - entity => { + (entity: any) => { entity.endpoint = 'doc' return finishCreatingDocOrFile(entity) }, @@ -461,7 +463,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => { ) const finishCreatingLinkedFile = useCallback( - entity => { + (entity: any) => { entity.endpoint = 'linked_file' return finishCreatingDocOrFile(entity) }, diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx index 93315ec836..8d9e0b596e 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-form.tsx @@ -16,7 +16,9 @@ export const useFileTreeCreateForm = () => { return context } -const FileTreeCreateFormProvider: FC = ({ children }) => { +const FileTreeCreateFormProvider: FC = ({ + children, +}) => { // is the form valid const [valid, setValid] = useState(false) diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx index ed9a8f4a38..59b2dc6fcc 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-create-name.tsx @@ -28,10 +28,9 @@ type State = { touchedName: boolean } -const FileTreeCreateNameProvider: FC<{ initialName?: string }> = ({ - children, - initialName = '', -}) => { +const FileTreeCreateNameProvider: FC< + React.PropsWithChildren<{ initialName?: string }> +> = ({ children, initialName = '' }) => { const [state, setName] = useReducer( (state: State, name: string) => ({ name, // the file name diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx index c255c25231..e6f8cac3f8 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.tsx @@ -18,9 +18,11 @@ import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file' import { FileTreeFindResult } from '@/features/ide-react/types/file-tree' const DRAGGABLE_TYPE = 'ENTITY' -export const FileTreeDraggableProvider: FC<{ - fileTreeContainer?: HTMLDivElement -}> = ({ fileTreeContainer, children }) => { +export const FileTreeDraggableProvider: FC< + React.PropsWithChildren<{ + fileTreeContainer?: HTMLDivElement + }> +> = ({ fileTreeContainer, children }) => { const options = useMemo( () => ({ rootElement: fileTreeContainer }), [fileTreeContainer] diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx index 85872fe129..d8cceb31c3 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.tsx @@ -25,11 +25,13 @@ export function useFileTreeMainContext() { return context } -export const FileTreeMainProvider: FC<{ - refProviders: object - setRefProviderEnabled: (provider: string, value: boolean) => void - setStartedFreeTrial: (value: boolean) => void -}> = ({ +export const FileTreeMainProvider: FC< + React.PropsWithChildren<{ + refProviders: object + setRefProviderEnabled: (provider: string, value: boolean) => void + setStartedFreeTrial: (value: boolean) => void + }> +> = ({ refProviders, setRefProviderEnabled, setStartedFreeTrial, diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx index 593d51c6e1..e0b860be87 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-path.tsx @@ -22,7 +22,9 @@ export const FileTreePathContext = createContext< FileTreePathContextValue | undefined >(undefined) -export const FileTreePathProvider: FC = ({ children }) => { +export const FileTreePathProvider: FC = ({ + children, +}) => { const { fileTreeData }: { fileTreeData: Folder } = useFileTreeData() const projectId = getMeta('ol-project_id') diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx index f52fe385e5..edd1334169 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx @@ -1,3 +1,6 @@ +// TODO: The types in this file have mismatches between string and string[] and +// it's not immediately clear how to resolve it. I've therefore left in a bunch +// of `any` types. We should fix this. import { createContext, useCallback, @@ -120,9 +123,11 @@ function fileTreeSelectableReadOnlyReducer( } } -export const FileTreeSelectableProvider: FC<{ - onSelect: (value: FindResult[]) => void -}> = ({ onSelect, children }) => { +export const FileTreeSelectableProvider: FC< + React.PropsWithChildren<{ + onSelect: (value: FindResult[]) => void + }> +> = ({ onSelect, children }) => { const { _id: projectId, rootDocId } = useProjectContext() const [initialSelectedEntityId] = usePersistedState( @@ -206,21 +211,24 @@ export const FileTreeSelectableProvider: FC<{ ) ) - const select = useCallback(id => { + const select = useCallback((id: any) => { dispatch({ type: ACTION_TYPES.SELECT, id }) }, []) - const unselect = useCallback(id => { + const unselect = useCallback((id: any) => { dispatch({ type: ACTION_TYPES.UNSELECT, id }) }, []) - const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => { - const actionType = isMultiSelect - ? ACTION_TYPES.MULTI_SELECT - : ACTION_TYPES.SELECT + const selectOrMultiSelectEntity = useCallback( + (id: any, isMultiSelect: any) => { + const actionType = isMultiSelect + ? ACTION_TYPES.MULTI_SELECT + : ACTION_TYPES.SELECT - dispatch({ type: actionType, id }) - }, []) + dispatch({ type: actionType, id }) + }, + [] + ) // TODO: wrap in useMemo const value = { @@ -254,7 +262,7 @@ export function useSelectableEntity(id: string, type: string) { const isSelected = selectedEntityIds.has(id) const buildSelectedRange = useCallback( - id => { + (id: string) => { const selected = [] let started = false @@ -306,7 +314,7 @@ export function useSelectableEntity(id: string, type: string) { }, [fileTreeData, selectedEntityIds, view]) const handleEvent = useCallback( - ev => { + (ev: any) => { ev.stopPropagation() // use Command (macOS) or Ctrl (other OS) to select multiple items, // as long as the root folder wasn't selected @@ -342,7 +350,7 @@ export function useSelectableEntity(id: string, type: string) { ) const handleClick = useCallback( - ev => { + (ev: any) => { handleEvent(ev) if (!ev.ctrlKey && !ev.metaKey) { setContextMenuCoords(null) @@ -352,7 +360,7 @@ export function useSelectableEntity(id: string, type: string) { ) const handleKeyPress = useCallback( - ev => { + (ev: any) => { if (ev.key === 'Enter' || ev.key === ' ') { handleEvent(ev) } @@ -361,7 +369,7 @@ export function useSelectableEntity(id: string, type: string) { ) const handleContextMenu = useCallback( - ev => { + (ev: any) => { // make sure the right-clicked entity gets selected if (!selectedEntityIds.has(id)) { handleEvent(ev) diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts index b6197ce5a9..a9f15721fc 100644 --- a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts +++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.ts @@ -25,7 +25,7 @@ export function useFileTreeSocketListener(onDelete: (entity: any) => void) { const selectEntityIfCreatedByUser = useCallback( // hack to automatically re-open refreshed linked files - (entityId, entityName, userId) => { + (entityId: any, entityName: any, userId: string) => { // If the created entity's user exists and is the current user if (userId && user?.id === userId) { // And we're expecting a refreshed socket for this entity diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx index 7163fd2cee..988876a4a0 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -16,6 +16,7 @@ import { } from 'react-bootstrap-5' import FormText from '@/features/ui/components/bootstrap-5/form/form-text' import Button from '@/features/ui/components/bootstrap-5/button' +import PoNumber from '@/features/group-management/components/add-seats/po-number' import CostSummary from '@/features/group-management/components/add-seats/cost-summary' import RequestStatus from '@/features/group-management/components/request-status' import useAsync from '@/shared/hooks/use-async' @@ -29,6 +30,7 @@ import { } from '../../../../../../types/subscription/subscription-change-preview' import { MergeAndOverride, Nullable } from '../../../../../../types/utils' import { sendMB } from '../../../../infrastructure/event-tracking' +import { useFeatureFlag } from '@/shared/context/split-test-context' export const MAX_NUMBER_OF_USERS = 20 @@ -43,8 +45,12 @@ function AddSeats() { const subscriptionId = getMeta('ol-subscriptionId') const totalLicenses = Number(getMeta('ol-totalLicenses')) const isProfessional = getMeta('ol-isProfessional') + const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual') const [addSeatsInputError, setAddSeatsInputError] = useState() const [shouldContactSales, setShouldContactSales] = useState(false) + const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag( + 'flexible-group-licensing-for-manually-billed-subscriptions' + ) const controller = useAbortController() const { signal: addSeatsSignal } = useAbortController() const { signal: contactSalesSignal } = useAbortController() @@ -151,6 +157,9 @@ function AddSeats() { formData.get('seats') === '' ? undefined : (formData.get('seats') as string) + const poNumber = !formData.get('po_number') + ? undefined + : (formData.get('po_number') as string) if (!(await validateSeats(rawSeats))) { return @@ -166,6 +175,7 @@ function AddSeats() { signal: contactSalesSignal, body: { adding: rawSeats, + poNumber, }, } ) @@ -176,7 +186,10 @@ function AddSeats() { }) const post = postJSON('/user/subscription/group/add-users/create', { signal: addSeatsSignal, - body: { adding: Number(rawSeats) }, + body: { + adding: Number(rawSeats), + poNumber, + }, }) runAsyncAddSeats(post) .then(() => { @@ -323,6 +336,8 @@ function AddSeats() { {addSeatsInputError} )} + {isFlexibleGroupLicensingForManuallyBilledSubscriptions && + isCollectionMethodManual && }
+ + setIsPoNumberChecked(e.target.checked)} + /> + + {isPoNumberChecked && ( + + {t('po_number')} + + + )} + + ) +} + +export default PoNumber diff --git a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx index e6d9ae4700..af6d74928d 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx @@ -1,5 +1,6 @@ import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' import AddSeats from '@/features/group-management/components/add-seats/add-seats' +import { SplitTestProvider } from '@/shared/context/split-test-context' function Root() { const { isReady } = useWaitForI18n() @@ -8,7 +9,11 @@ function Root() { return null } - return + return ( + + + + ) } export default Root diff --git a/services/web/frontend/js/features/group-management/components/group-members.tsx b/services/web/frontend/js/features/group-management/components/group-members.tsx index be4f84375c..2e87fe5051 100644 --- a/services/web/frontend/js/features/group-management/components/group-members.tsx +++ b/services/web/frontend/js/features/group-management/components/group-members.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { ChangeEvent, useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n' import getMeta from '../../../utils/meta' @@ -37,7 +37,7 @@ export default function GroupMembers() { const canUseAddSeatsFeature = getMeta('ol-canUseAddSeatsFeature') const handleEmailsChange = useCallback( - e => { + (e: ChangeEvent) => { setEmailString(e.target.value) }, [setEmailString] diff --git a/services/web/frontend/js/features/group-management/components/managers-table.tsx b/services/web/frontend/js/features/group-management/components/managers-table.tsx index 5efe2ffbb9..3f701b698d 100644 --- a/services/web/frontend/js/features/group-management/components/managers-table.tsx +++ b/services/web/frontend/js/features/group-management/components/managers-table.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { ChangeEvent, FormEvent, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { deleteJSON, FetchError, postJSON } from '@/infrastructure/fetch-json' import getMeta from '../../../utils/meta' @@ -58,7 +58,7 @@ export function ManagersTable({ const [removeMemberError, setRemoveMemberError] = useState() const addManagers = useCallback( - e => { + (e: FormEvent | React.MouseEvent) => { e.preventDefault() setInviteError(undefined) const emails = parseEmails(emailString) @@ -92,7 +92,7 @@ export function ManagersTable({ ) const removeManagers = useCallback( - e => { + (e: React.MouseEvent) => { e.preventDefault() setRemoveMemberError(undefined) ;(async () => { @@ -131,7 +131,7 @@ export function ManagersTable({ ) const handleEmailsChange = useCallback( - e => { + (e: ChangeEvent) => { setEmailString(e.target.value) }, [setEmailString] diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx index 691a4b3636..a78f08d40c 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx @@ -64,7 +64,7 @@ export default function DropdownButton({ const isUserManaged = !userPending && user.enrollment?.managedBy === groupId const handleResendManagedUserInvite = useCallback( - async user => { + async (user: User) => { try { const result = await runResendManagedUserInviteAsync( postJSON( @@ -96,7 +96,7 @@ export default function DropdownButton({ ) const handleResendLinkSSOInviteAsync = useCallback( - async user => { + async (user: User) => { try { const result = await runResendLinkSSOInviteAsync( postJSON(`/manage/groups/${groupId}/resendSSOLinkInvite/${user._id}`) @@ -126,7 +126,7 @@ export default function DropdownButton({ ) const handleResendGroupInvite = useCallback( - async user => { + async (user: User) => { try { await runResendGroupInviteAsync( postJSON(`/manage/groups/${groupId}/resendInvite/`, { diff --git a/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx b/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx index 5bc234a789..4528aab13f 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx @@ -101,7 +101,6 @@ export default function OffboardManagedUserModal({ setSelectedRecipientId(e.target.value)} > diff --git a/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx b/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx index a2d32810bc..19aec5d6c7 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx @@ -16,7 +16,7 @@ export default function SelectUserCheckbox({ useGroupMembersContext() const handleSelectUser = useCallback( - (event, user) => { + (event: React.ChangeEvent, user: User) => { if (event.target.checked) { selectUser(user) } else { diff --git a/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx b/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx index f63bda765e..ce583a09e3 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx @@ -47,7 +47,7 @@ export default function UnlinkUserModal({ }, [groupId, updateMemberView, user]) const handleUnlink = useCallback( - event => { + (event: React.MouseEvent) => { event.preventDefault() setHasError(undefined) if (!user) { @@ -108,7 +108,11 @@ export default function UnlinkUserModal({

- + {t('cancel')} , // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key diff --git a/services/web/frontend/js/features/group-management/components/user-row.tsx b/services/web/frontend/js/features/group-management/components/user-row.tsx index 895cc69f04..505dd83e76 100644 --- a/services/web/frontend/js/features/group-management/components/user-row.tsx +++ b/services/web/frontend/js/features/group-management/components/user-row.tsx @@ -21,7 +21,7 @@ export default function UserRow({ const { t } = useTranslation() const handleSelectUser = useCallback( - (event, user) => { + (event: React.ChangeEvent, user: User) => { if (event.target.checked) { selectUser(user) } else { diff --git a/services/web/frontend/js/features/group-management/context/group-members-context.tsx b/services/web/frontend/js/features/group-management/context/group-members-context.tsx index 748d845e9d..63619dc361 100644 --- a/services/web/frontend/js/features/group-management/context/group-members-context.tsx +++ b/services/web/frontend/js/features/group-management/context/group-members-context.tsx @@ -72,7 +72,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { ) const addMembers = useCallback( - emailString => { + (emailString: string) => { setInviteError(undefined) const emails = parseEmails(emailString) mapSeries(emails, async email => { @@ -102,7 +102,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { ) const removeMember = useCallback( - async user => { + async (user: User) => { let url if (paths.removeInvite && user.invite && user._id == null) { url = `${paths.removeInvite}/${encodeURIComponent(user.email)}` @@ -126,7 +126,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { ) const removeMembers = useCallback( - e => { + (e: any) => { e.preventDefault() setRemoveMemberError(undefined) ;(async () => { @@ -142,7 +142,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { ) const updateMemberView = useCallback( - (userId, updatedUser) => { + (userId: string, updatedUser: User) => { setUsers( users.map(u => { if (u._id === userId) { diff --git a/services/web/frontend/js/features/header-footer-react/index.tsx b/services/web/frontend/js/features/header-footer-react/index.tsx index e808b58322..ebbf4ddecf 100644 --- a/services/web/frontend/js/features/header-footer-react/index.tsx +++ b/services/web/frontend/js/features/header-footer-react/index.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createRoot } from 'react-dom/client' import getMeta from '@/utils/meta' import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar' import Footer from '@/features/ui/components/bootstrap-5/footer/footer' @@ -7,16 +7,17 @@ import { SplitTestProvider } from '@/shared/context/split-test-context' const navbarElement = document.getElementById('navbar-container') if (navbarElement) { const navbarProps = getMeta('ol-navbar') - ReactDOM.render( + const root = createRoot(navbarElement) + root.render( - , - navbarElement + ) } const footerElement = document.getElementById('footer-container') if (footerElement) { const footerProps = getMeta('ol-footer') - ReactDOM.render(