diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..a12fac3d0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Force LF everywhere: these files run inside Linux containers +* text=auto eol=lf +*.png binary +*.jpg binary +*.ico binary +*.woff binary +*.woff2 binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f3fddbc0c..39e5c0ec6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,16 @@ - + +WARNINGS: + +1. An AI bot will comment on this PR! If you don't get a perfect score or don't +address any issues raised, your PR won't get merged! + +2. Any PR without a description will be closed. The title is not enough! + +3. Don't go creating a duplicate PR! Check that someone hasn't already created +something that tackles your fix and if so, help them first + +--> ## Why @@ -28,4 +38,3 @@ you change will break API for existing users --> - [ ] AI was used to write this - [ ] AI was used to review this - diff --git a/.gitignore b/.gitignore index a83a2110e..667601ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ test/node_modules docker/dev/dnsrouter-config.json.tmp docker/dev/resolv.conf .claude +test/__pycache__ diff --git a/.version b/.version index 752490696..d76bd2ba3 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.16.0 +2.17.0 diff --git a/README.md b/README.md index 0ee51c350..76a24316b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@



- + diff --git a/backend/biome.json b/backend/biome.json index 5d1794b5a..0f1b5b62c 100644 --- a/backend/biome.json +++ b/backend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json index 4467a27af..b66c2684c 100644 --- a/backend/certbot/dns-plugins.json +++ b/backend/certbot/dns-plugins.json @@ -44,16 +44,16 @@ "dependencies": "", "full_plugin_name": "dns-baidu", "name": "baidu", - "package_name": "certbot-dns-baidu", - "version": "~=0.1.1" + "package_name": "certbot-dns-baidu-bcd", + "version": "~=2.0.0" }, "beget": { - "credentials": "# Beget API credentials used by Certbot\nbeget_plugin_username = username\nbeget_plugin_password = password", + "credentials": "# Beget API credentials used by Certbot\n# Find on Settings -> Security -> BegetAPI\ndns_beget_plugin_username = username\ndns_beget_plugin_password = password", "dependencies": "", - "full_plugin_name": "beget-plugin", + "full_plugin_name": "dns-beget-plugin", "name": "Beget", - "package_name": "certbot-beget-plugin", - "version": "~=1.0.0.dev9" + "package_name": "certbot-dns-beget-plugin", + "version": "~=1.0.0" }, "bunny": { "credentials": "# Bunny API token used by Certbot (see https://dash.bunny.net/account/settings)\ndns_bunny_api_key = xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx", @@ -173,7 +173,7 @@ "full_plugin_name": "dns-multi", "name": "DnsMulti", "package_name": "certbot-dns-multi", - "version": "~=4.9" + "version": "~=5.6.0" }, "dnspod": { "credentials": "dns_dnspod_email = \"email@example.com\"\ndns_dnspod_api_token = \"id,key\"", @@ -224,12 +224,12 @@ "version": "~=0.1.2" }, "edgedns": { - "credentials": "edgedns_client_secret = as3d1asd5d1a32sdfsdfs2d1asd5=\nedgedns_host = sdflskjdf-dfsdfsdf-sdfsdfsdf.luna.akamaiapis.net\nedgedns_access_token = kjdsi3-34rfsdfsdf-234234fsdfsdf\nedgedns_client_token = dkfjdf-342fsdfsd-23fsdfsdfsdf", - "dependencies": "", - "full_plugin_name": "edgedns", - "name": "Akamai Edge DNS", - "package_name": "certbot-plugin-edgedns", - "version": "~=0.1.0" + "credentials": "client_token = YOUR_CLIENT_TOKEN\naccess_token = YOUR_ACCESS_TOKEN\nclient_secret = YOUR_CLIENT_SECRET\nhost = YOUR_HOST.luna.akamaiapis.net\n# Optional: account_key = YOUR_ACCOUNT_KEY", + "dependencies": "", + "full_plugin_name": "edgedns", + "name": "Akamai Edge DNS", + "package_name": "certbot-plugin-edgedns", + "version": "==0.3.0" }, "edgeone": { "credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY", @@ -333,7 +333,7 @@ "full_plugin_name": "dns-hetzner", "name": "Hetzner", "package_name": "certbot-dns-hetzner", - "version": "~=1.0.4" + "version": "~=4.0.0" }, "hetzner-cloud": { "credentials": "dns_hetzner_cloud_api_token = your_api_token_here", @@ -381,7 +381,7 @@ "full_plugin_name": "dns-infomaniak", "name": "Infomaniak", "package_name": "certbot-dns-infomaniak", - "version": "~=0.2.2" + "version": "~=0.3.3" }, "inwx": { "credentials": "dns_inwx_url = https://api.domrobot.com/xmlrpc/\ndns_inwx_username = your_username\ndns_inwx_password = your_password\ndns_inwx_shared_secret = your_shared_secret optional", @@ -399,6 +399,14 @@ "package_name": "certbot-dns-ionos", "version": "==2022.11.24" }, + "ip-projects": { + "credentials": "dns_ipp_acme_api_key = ", + "dependencies": "", + "full_plugin_name": "dns-ipp", + "name": "ip-projects.de", + "package_name": "certbot-dns-ipp", + "version": "~=1.0.0" + }, "ispconfig": { "credentials": "dns_ispconfig_username = myremoteuser\ndns_ispconfig_password = verysecureremoteuserpassword\ndns_ispconfig_endpoint = https://localhost:8080", "dependencies": "", @@ -599,6 +607,14 @@ "package_name": "certbot-dns-route53", "version": "=={{certbot-version}}" }, + "scaleway": { + "credentials": "dns_scaleway_application_token = b3a0b9a9-3814-4f12-95b0-a7473bf8b306", + "dependencies": "", + "full_plugin_name": "dns-scaleway", + "name": "Scaleway", + "package_name": "certbot-dns-scaleway", + "version": "~=0.0.7" + }, "selectelv2": { "credentials": "dns_selectel_api_v2_account_id = your_account_id\ndns_selectel_api_v2_project_name = your_project\ndns_selectel_api_v2_username = your_username\ndns_selectel_api_v2_password = your_password", "dependencies": "", diff --git a/backend/index.js b/backend/index.js index 002856672..a6afda7bb 100644 --- a/backend/index.js +++ b/backend/index.js @@ -26,7 +26,9 @@ async function appStart() { }) .then(() => { internalCertificate.initTimer(); - internalIpRanges.initTimer(); + if (IP_RANGES_FETCH_ENABLED) { + internalIpRanges.initTimer(); + } const server = app.listen(3000, () => { logger.info(`Backend PID ${process.pid} listening on port 3000 ...`); diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 1fdd65b6a..33b49c5d3 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -2,6 +2,7 @@ import fs from "node:fs"; import batchflow from "batchflow"; import _ from "lodash"; import errs from "../lib/error.js"; +import { isValid, unsafeValueError } from "../lib/nginx-values.js"; import { isMysql, isPostgres } from "../lib/config.js"; import utils from "../lib/utils.js"; import db from "../db.js"; @@ -27,20 +28,18 @@ const getProxyHostsUsingAccessListInLocations = async (accessListId) => { let result; if (isMysql()) { const searchObj = JSON.stringify([{ access_list_id: accessListId }]); - result = await db().raw( - "SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)", - [searchObj, "$"], - ); + result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)", [ + searchObj, + "$", + ]); } else if (isPostgres()) { - result = await db().raw( - "SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb", - [JSON.stringify([{ access_list_id: accessListId }])], - ); + result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb", [ + JSON.stringify([{ access_list_id: accessListId }]), + ]); } else { - result = await db().raw( - "SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?", - [`%"access_list_id":${accessListId}%`], - ); + result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?", [ + `%"access_list_id":${accessListId}%`, + ]); } // knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite if (!Array.isArray(result)) { @@ -225,12 +224,15 @@ const internalAccessList = { // Also regenerate configs for proxy hosts that reference this access list in their locations const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id); if (locationHostRows?.length) { - const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => { - // Exclude hosts already regenerated above - return !freshRow.proxy_hosts?.find((h) => h.id === id); - }); + const locationHostIds = locationHostRows + .map((r) => r.id) + .filter((id) => { + // Exclude hosts already regenerated above + return !freshRow.proxy_hosts?.find((h) => h.id === id); + }); if (locationHostIds.length) { - const locationHosts = await proxyHostModel.query() + const locationHosts = await proxyHostModel + .query() .where("is_deleted", 0) .whereIn("id", locationHostIds) .allowGraph(proxyHostModel.defaultAllowGraph) @@ -499,6 +501,11 @@ const internalAccessList = { .sequential() .each((_i, item, next) => { if (item.password?.length) { + // One "user:hash" line each; a ":" or line break in the name would corrupt the file + if (!isValid("accessUsername", item.username)) { + reject(unsafeValueError(`Access List #${list.id}: user name is not allowed`)); + return; + } logger.info(`Adding: ${item.username}`); utils diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 963d2bb6a..0c3166a60 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -24,8 +24,163 @@ const certbotCommand = "certbot"; const certbotLogsDir = "/data/logs"; const certbotWorkDir = "/tmp/letsencrypt-lib"; +// Certbot output lines that say nothing about why a request failed +const certbotNoise = [ + /^Saving debug log/, + /^Ask for help/, + /^See the logfile/, + /^(- )+-?$/, + /^Processing \//, + /^Renewing an existing/, + /^Requesting a certificate/, + /^Plugins selected/, + /^Account registered/, + /^Waiting \d+ seconds/, + /^All renewals failed/, + /^\S+\/fullchain\.pem \(failure\)$/, + /^\d+ renew failure\(s\)/, +]; + +// Plain-language hints for failures that are common and fixable outside NPM. +// [pattern, hint, dnsProvider?]: a hint with a dnsProvider only applies to certificates using that +// provider, so a generic message (e.g. "Authentication error" from another DNS plugin) isn't +// reported as a Cloudflare problem. +const certbotHints = [ + // Checked first: a rate limit is unambiguous and the most useful thing to report + [ + /too many (certificates|failed authorizations|new orders)|rateLimited/i, + () => "Let's Encrypt rate limit reached. Wait before retrying (see https://letsencrypt.org/docs/rate-limits/).", + ], + [ + /expected \S+ to be a symlink/i, + () => + "This certificate's files in /etc/letsencrypt were copied without their symlinks (live/ must link to archive/). Restore /etc/letsencrypt from a copy made with tar or rsync -a, or delete this certificate and create it again.", + ], + [ + /Renewal configuration file \S+ is broken/i, + () => + "This certificate's files in /etc/letsencrypt are damaged or missing. Restore /etc/letsencrypt from a backup, or delete this certificate and create it again.", + ], + [ + /\b9109\b.*?location: ([0-9a-f.:]+)/i, + (m) => { + const ip = m[1].replace(/\.+$/, ""); + return `Cloudflare refused the API token from this server's public IP (${ip}). The token has Client IP Address Filtering: add ${ip} to the token in Cloudflare (My Profile > API Tokens), or remove the filter, then renew again.`; + }, + "cloudflare", + ], + [ + // certbot-dns-cloudflare errors read ": ", e.g. + // "Error determining zone_id: 9109 Invalid access token." + /: (6003|6111|9103|9109|10000) [A-Za-z]|Invalid request headers|Invalid access token|Authentication error/i, + () => + "Cloudflare rejected the API token. Check the token value and that it has Zone > DNS > Edit for this domain's zone.", + "cloudflare", + ], + [ + /NXDOMAIN|DNS problem/i, + () => "Let's Encrypt could not look up the domain. Check the DNS records of every domain on the certificate.", + ], + [ + /Timeout during connect|Connection refused|Invalid response from/i, + () => + "Let's Encrypt could not reach this server over HTTP (port 80) for the challenge. Check that the domain points here and port 80 is open, or use a DNS challenge.", + ], +]; + +/** + * The values in a DNS credentials file ("key = value" lines), for redacting from certbot output. + * + * @param {String} [credentials] + * @returns {String[]} + */ +const credentialValues = (credentials) => + `${credentials || ""}` + .split("\n") + .map((line) => line.replace(/^[^=:]*[=:]/, "").trim()) + .filter((value) => value.length >= 4); + +/** + * Removes anything that could be a secret from certbot output before it leaves the server. + * DNS plugins can echo provider errors verbatim, including request URLs that carry API keys in + * the query string (e.g. "401 Client Error ... for url: https://...?token=..."), so this strips + * the given credential values, URL query strings and userinfo, and key=value pairs whose name + * looks secret. + * + * @param {String} text + * @param {String[]} [secrets] + * @returns {String} + */ +const redactCertbotOutput = (text, secrets = []) => { + let out = `${text || ""}`; + for (const secret of [...secrets].sort((a, b) => b.length - a.length)) { + out = out.split(secret).join("[REDACTED]"); + } + return out + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") + .replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s@]*@/gi, "$1[REDACTED]@") + .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)\?[^\s#]*/gi, "$1?[REDACTED]") + .replace( + /\b([\w-]*(?:token|key|secret|password|passwd|pass|auth|credential|signature|sig)[\w-]*)(["']?\s*[=:]\s*["']?)[^\s"'&,;]+/gi, + "$1$2[REDACTED]", + ); +}; + +/** + * Turns certbot's stderr into a message the UI can show: a hint for common causes plus certbot's own reason. + * The output is redacted first (see redactCertbotOutput), since DNS plugins can echo credentials. + * + * @param {String} stderr + * @param {String} [dnsProvider] The certificate's DNS provider, if it uses a DNS challenge + * @param {String[]} [secrets] Values that must never appear in the message + * @returns {String} + */ +const certbotFailureMessage = (stderr, dnsProvider, secrets) => { + const reason = redactCertbotOutput(stderr, secrets) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !certbotNoise.some((re) => re.test(line))) + .join(" ") + .slice(0, 800); + + for (const [pattern, hint, provider] of certbotHints) { + if (provider && provider !== dnsProvider) { + continue; + } + const match = reason.match(pattern); + if (match) { + return `${hint(match)} (Certbot: ${reason})`; + } + } + return reason ? `Certbot failed: ${reason}` : "Certbot failed. See /data/logs/letsencrypt.log for details."; +}; + +/** + * Runs certbot; a failure becomes a public error with the real reason instead of "Internal Error". + * + * @param {Array} args + * @param {Object} [opts] + * @param {String} [dnsProvider] Passed through so provider-specific hints only show for that provider + * @param {String} [credentials] The DNS credentials file contents, so its values are redacted from errors + * @returns {Promise} + */ +const runCertbot = async (args, opts, dnsProvider, credentials) => { + try { + return await utils.execFile(certbotCommand, args, opts); + } catch (err) { + if (err instanceof error.CommandError) { + const secrets = credentialValues(credentials); + logger.error(redactCertbotOutput(err.message, secrets)); + // The raw error is not attached as `previous`: the API error handler returns + // `previous` to the client for certificate routes, and it holds unredacted output. + throw new error.ValidationError(certbotFailureMessage(err.message, dnsProvider, secrets)); + } + throw err; + } +}; + const omissions = () => { - return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials"]; + return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials", "meta.certificate_key"]; }; const internalCertificate = { @@ -75,6 +230,7 @@ const internalCertificate = { * instance of Certbot is already running.' */ let sequence = Promise.resolve(); + let renewed = 0; certificates.forEach((certificate) => { sequence = sequence.then(() => @@ -88,7 +244,11 @@ const internalCertificate = { token: tokenModel(), }, { id: certificate.id }, + { reload: false }, // one reload for the whole batch, below ) + .then(() => { + renewed++; + }) .catch((err) => { // Don't want to stop the train here, just log the error logger.error(err.message); @@ -96,7 +256,11 @@ const internalCertificate = { ); }); - return sequence; + return sequence.then(() => { + if (renewed) { + return internalCertificate.reloadAfterRenew(); + } + }); }) .then(() => { logger.info("Completed SSL cert renew process"); @@ -395,12 +559,35 @@ const internalCertificate = { */ delete: async (access, data) => { await access.can("certificates:delete", data.id); - const row = await internalCertificate.get(access, { id: data.id }); + const hostRelations = ["proxy_hosts", "redirection_hosts", "dead_hosts", "streams"]; + const row = await internalCertificate.get(access, { id: data.id, expand: hostRelations }); if (!row?.id) { throw new error.ItemNotFoundError(data.id); } + // A certificate that hosts still use can't be deleted: their rendered configs include its + // files, and revoking it would leave them serving a revoked certificate. Hosts may belong + // to other users, so only counts are reported. + const labels = { + proxy_hosts: "proxy host", + redirection_hosts: "redirection host", + dead_hosts: "404 host", + streams: "stream", + }; + const usage = hostRelations + .map((rel) => ({ rel, count: row[rel]?.length || 0 })) + .filter((u) => u.count > 0) + .map((u) => `${u.count} ${labels[u.rel]}${u.count === 1 ? "" : "s"}`); + if (usage.length) { + throw new error.ValidationError( + `Certificate is in use by ${usage.join(", ")}. Remove it from them before deleting it.`, + ); + } + for (const rel of hostRelations) { + delete row[rel]; + } + await certificateModel.query().where("id", row.id).patch({ is_deleted: 1, }); @@ -416,12 +603,47 @@ const internalCertificate = { }); if (row.provider === "letsencrypt") { - // Revoke the cert + // Revoke the cert. Errors are logged by revokeLetsEncryptSsl. await internalCertificate.revokeLetsEncryptSsl(row); + // Certbot leaves the files behind when it can't revoke (e.g. a broken renewal config), + // so remove them either way. No host uses them; that was checked above. + // The row is already deleted and audited, so leftover files are logged, not thrown: + // an error here would report a completed delete as failed, and a retry would 404. + const leftover = internalCertificate.removeLetsEncryptFiles(row.id); + if (leftover.length) { + logger.warn( + `Certificate #${row.id} was deleted, but these files could not be removed and should be deleted by hand: ${leftover.join(", ")}`, + ); + } } return true; }, + /** + * Removes a certificate's certbot files, as `certbot delete` would. Never throws. + * + * @param {Number} certificateId + * @returns {String[]} paths that could not be removed + */ + removeLetsEncryptFiles: (certificateId) => { + const paths = [ + internalCertificate.getLiveCertPath(certificateId), + `/etc/letsencrypt/archive/npm-${certificateId}`, + `/etc/letsencrypt/renewal/npm-${certificateId}.conf`, + `/etc/letsencrypt/credentials/credentials-${certificateId}`, + ]; + const failed = []; + for (const p of paths) { + try { + fs.rmSync(p, { recursive: true, force: true }); + } catch (err) { + logger.error(`Could not remove ${p}: ${err.message}`); + failed.push(p); + } + } + return failed; + }, + /** * All Certs * @@ -545,13 +767,15 @@ const internalCertificate = { /** * Validates that the certs provided are good. - * No access required here, nothing is changed or stored. + * Nothing is changed or stored, but the files go through openssl, so it needs the same access as creating one. * + * @param {Access} access * @param {Object} data * @param {Object} data.files * @returns {Promise} */ - validate: (data) => { + validate: async (access, data) => { + await access.can("certificates:create"); // Put file contents into an object const files = {}; _.map(data.files, (file, name) => { @@ -600,27 +824,75 @@ const internalCertificate = { throw new error.ValidationError("Cannot upload certificates for this type of provider"); } - const validations = await internalCertificate.validate(data); + const validations = await internalCertificate.validate(access, data); if (typeof validations.certificate === "undefined") { throw new error.ValidationError("Certificate file was not provided"); } + // row came through get(), which omits meta.certificate_key so it never goes out over + // the API. update() replaces meta wholesale, so build on the stored meta (read from the + // model directly) or an upload without a key would erase the stored one. + const stored = await certificateModel.query().where("id", row.id).first(); + const meta = { ...(stored?.meta || {}) }; + const uploaded = []; + _.map(data.files, (file, name) => { if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) { - row.meta[name] = file.data.toString(); + meta[name] = file.data.toString(); + uploaded.push(name); } }); + // Fail before anything is written: without a key, writeCustomCert would overwrite + // fullchain.pem and then fail on privkey.pem, leaving nginx with a mismatched pair. + if (!meta.certificate_key) { + throw new error.ValidationError("Certificate Key file was not provided"); + } + // The key may be the stored one (a certificate-only upload), so check the pair belongs + // together; a mismatch would make nginx -t fail for every host on the next reload. + await internalCertificate.checkKeyMatchesCertificate(meta.certificate, meta.certificate_key); + const certificate = await internalCertificate.update(access, { id: data.id, expires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"), domain_names: validations.certificate.cn ? [validations.certificate.cn] : [], - meta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later + meta: _.clone(meta), // Prevent the update method from changing this value that we'll use later }); - certificate.meta = row.meta; + certificate.meta = meta; await internalCertificate.writeCustomCert(certificate); - return _.pick(row.meta, internalCertificate.allowedSslFiles); + // Only echo back what was uploaded in this request, never the stored key + return _.pick(meta, uploaded); + }, + + /** + * Throws unless the private key is the one the certificate was issued for, by comparing the + * public key derived from each. + * + * @param {String} certificate PEM certificate (the leaf comes first) + * @param {String} privateKey PEM private key + * @returns {Promise} + */ + checkKeyMatchesCertificate: async (certificate, privateKey) => { + const certPath = await tempWrite(certificate); + const keyPath = await tempWrite(privateKey); + try { + const [certPub, keyPub] = await Promise.all([ + utils.execFile("openssl", ["x509", "-in", certPath, "-pubkey", "-noout"]), + utils.execFile("openssl", ["pkey", "-in", keyPath, "-pubout"]), + ]); + if (!certPub.trim() || certPub.trim() !== keyPub.trim()) { + throw new error.ValidationError("Certificate does not match the Certificate Key"); + } + } catch (err) { + if (err instanceof error.ValidationError) { + throw err; + } + throw new error.ValidationError("Could not check that the Certificate matches the Certificate Key"); + } finally { + fs.rmSync(certPath, { force: true }); + fs.rmSync(keyPath, { force: true }); + } }, /** @@ -811,7 +1083,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); logger.success(result); return result; }, @@ -860,7 +1132,8 @@ const internalCertificate = { if (hasConfigArg) { args.push(`--${dnsPlugin.full_plugin_name}-credentials`, credentialsLocation); } - if (certificate.meta.propagation_seconds !== undefined) { + // The route53 plugin waits for the change through the AWS API and has no propagation option + if (certificate.meta.propagation_seconds !== undefined && certificate.meta.dns_provider !== "route53") { args.push( `--${dnsPlugin.full_plugin_name}-propagation-seconds`, certificate.meta.propagation_seconds.toString(), @@ -878,7 +1151,12 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); try { - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot( + args, + adds.opts, + certificate.meta.dns_provider, + certificate.meta.dns_provider_credentials, + ); logger.info(result); return result; } finally { @@ -904,7 +1182,8 @@ const internalCertificate = { * @param {Number} data.id * @returns {Promise} */ - renew: async (access, data) => { + renew: async (access, data, options = {}) => { + const reload = options.reload !== false; await access.can("certificates:update", data); const certificate = await internalCertificate.get(access, data); @@ -930,12 +1209,34 @@ const internalCertificate = { meta: updatedCertificate, }); + // The renewal has succeeded and been recorded at this point. A reload failure + // (e.g. an unrelated host's config is broken) must not turn it into an error; + // nginx picks the new files up on the next successful reload. + if (reload) { + await internalCertificate.reloadAfterRenew(); + } + return updatedCertificate; } throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed"); }, + /** + * Reloads nginx so it serves renewed certificate files. Errors are logged, not thrown. + * + * @returns {Promise} whether the reload succeeded + */ + reloadAfterRenew: async () => { + try { + await internalNginx.reload(); + return true; + } catch (err) { + logger.error(`Certificate renewed but nginx could not be reloaded: ${err.message}`); + return false; + } + }, + /** * @param {Object} certificate the certificate row * @returns {Promise} @@ -972,7 +1273,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); logger.info(result); return result; }, @@ -1056,7 +1357,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); try { - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts, certificate.meta.dns_provider, credentials); logger.info(result); return result; } finally { @@ -1099,7 +1400,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); try { - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); await utils.exec(`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`); logger.info(result); return result; diff --git a/backend/internal/dead-host.js b/backend/internal/dead-host.js index 6523e6289..5e249de59 100644 --- a/backend/internal/dead-host.js +++ b/backend/internal/dead-host.js @@ -54,6 +54,8 @@ const internalDeadHost = { thisData.advanced_config = ""; } + internalHost.assertAdvancedConfigAllowed(access, thisData); + const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); // Add to audit log @@ -130,6 +132,9 @@ const internalDeadHost = { ); } + // Before the certificate is created, so a refused request doesn't leave one behind + internalHost.assertAdvancedConfigAllowed(access, data, row); + if (createCertificate) { const cert = await internalCertificate.createQuickCertificate(access, { domain_names: data.domain_names || row.domain_names, diff --git a/backend/internal/host.js b/backend/internal/host.js index 748716262..7bdaa7837 100644 --- a/backend/internal/host.js +++ b/backend/internal/host.js @@ -1,10 +1,110 @@ import _ from "lodash"; +import errs from "../lib/error.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import deadHostModel from "../models/dead_host.js"; import proxyHostModel from "../models/proxy_host.js"; import redirectionHostModel from "../models/redirection_host.js"; const internalHost = { + /** + * Only admins may set or change advanced_config (raw nginx config). Throws for anyone else. + * On create (no `row`), any non-empty advanced_config is refused, including on locations. + * On update, the host's advanced_config must be unchanged (or omitted), and locations must + * only carry advanced configs the host already has (see assertLocationAdvancedConfigUnchanged). + * + * @param {Access} access + * @param {Object} data incoming host data; data.locations may be filled in on update + * @param {Object} [row] the existing host, when updating + * @throws {PermissionError} + */ + assertAdvancedConfigAllowed: (access, data, row) => { + if (access.isAdmin?.()) { + return; + } + const deny = () => { + throw new errs.PermissionError("You do not have permission to modify advanced configuration"); + }; + + if (!row) { + const hasConfig = (v) => typeof v === "string" && v.trim() !== ""; + if (hasConfig(data.advanced_config)) { + deny(); + } + if (Array.isArray(data.locations) && data.locations.some((loc) => hasConfig(loc?.advanced_config))) { + deny(); + } + return; + } + + if (typeof data.advanced_config !== "undefined" && data.advanced_config !== row.advanced_config) { + deny(); + } + if (Array.isArray(data.locations)) { + internalHost.assertLocationAdvancedConfigUnchanged(data.locations, row.locations); + } + }, + + /** + * For non-admins: every location's advanced_config must be empty or one that already exists + * on this host. Matching is by value, not path, so renaming or reordering a location that + * carries admin-written config is allowed, but new or edited config is not. Each existing + * config can be used once (no duplicating it onto other paths), and same-path matches are + * taken first. Clearing a config is allowed: deleting the location is, and has the same + * effect, and a location deleted and re-added at the same path arrives with an empty config. + * A location that omits advanced_config keeps the one at its path, unless another location + * explicitly carries it. Mutates `locations`. + * + * @param {Array} locations incoming locations + * @param {Array} [existingLocations] the host's current locations + * @throws {PermissionError} + */ + assertLocationAdvancedConfigUnchanged: (locations, existingLocations) => { + const existing = Array.isArray(existingLocations) ? existingLocations : []; + const advAtPath = (path) => existing.find((l) => l.path === path)?.advanced_config || ""; + const pool = existing.map((l) => l.advanced_config || "").filter(Boolean); + const take = (value) => { + const i = pool.indexOf(value); + if (i === -1) { + return false; + } + pool.splice(i, 1); + return true; + }; + const deny = () => { + throw new errs.PermissionError("You do not have permission to modify advanced configuration"); + }; + + // Pass 1: locations that keep the config at their own path + const unresolved = []; + const omitted = []; + for (const loc of locations) { + const atPath = advAtPath(loc.path); + if (typeof loc.advanced_config === "undefined") { + omitted.push(loc); + } else if ((loc.advanced_config || "") === atPath && (!atPath || take(atPath))) { + // unchanged + } else { + unresolved.push(loc); + } + } + + // Pass 2: renamed/moved locations may carry an existing config not already claimed, or + // none at all + for (const loc of unresolved) { + if (loc.advanced_config && !take(loc.advanced_config)) { + deny(); + } + } + + // Pass 3: locations that omit advanced_config keep the one at their path, if no location + // that names it explicitly has claimed it. Running this last stops a new location at an + // old path taking the config away from the location it was renamed to. + for (const loc of omitted) { + const atPath = advAtPath(loc.path); + loc.advanced_config = atPath && take(atPath) ? atPath : ""; + } + }, + /** * Makes sure that the ssl_* and hsts_* fields play nicely together. * ie: if there is no cert, then force_ssl is off. @@ -79,6 +179,9 @@ const internalHost = { redirection_hosts: [], }; + // No relations are eager-loaded here: this scans every host just to match domains, and + // the few that match are rendered through internalNginx.generateConfig(), which loads + // their certificate and access list itself (loadRenderRelations). const proxyRes = await proxyHostModel.query().where("is_deleted", 0); responseObject.proxy_hosts = internalHost._getHostsWithDomains(proxyRes, domainNames); responseObject.total_count += responseObject.proxy_hosts.length; diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index a50e0780d..06180e011 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -4,9 +4,11 @@ import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import _ from "lodash"; import errs from "../lib/error.js"; +import { assertAccessListRenderable, assertHostRenderable, isTokenSafe, unsafeValueError } from "../lib/nginx-values.js"; import utils from "../lib/utils.js"; import { debug, nginx as logger } from "../logger.js"; import accessListModel from "../models/access_list.js"; +import certificateModel from "../models/certificate.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -39,9 +41,30 @@ const internalNginx = { return internalNginx.deleteConfig(host_type, host, true); }) .then(() => { - return internalNginx.generateConfig(host_type, host); + return internalNginx.generateConfig(host_type, host).then( + () => true, + (err) => { + if (!err.unsafeNginxValue) { + throw err; + } + // Nothing was written. Report it on the host like a failed nginx test. + logger.error(err.message); + combined_meta = _.assign({}, host.meta, { + nginx_online: false, + nginx_err: err.message, + }); + return model + .query() + .where("id", host.id) + .patch({ meta: combined_meta }) + .then(() => false); + }, + ); }) - .then(() => { + .then((generated) => { + if (!generated) { + return; + } // Test nginx again and update meta with result return internalNginx .test() @@ -181,7 +204,21 @@ const internalNginx = { .first(); } if (locationAccessList) { + assertAccessListRenderable( + `Proxy Host #${host.id} location ${locationCopy.path}`, + locationAccessList, + ); locationCopy.access_list = locationAccessList; + } else if ( + locationCopy.access_list_id > 0 && + locationCopy.access_list_id !== host.access_list?.id + ) { + // The location names its own list but it's gone. Don't fall back to the + // host's (possibly less restrictive) list; _access.conf denies all instead. + logger.error( + `Proxy Host #${host.id} location "${locationCopy.path}" references Access List #${locationCopy.access_list_id} which does not exist, all access will be denied`, + ); + locationCopy.access_list = null; } else { locationCopy.access_list_id = host.access_list_id; locationCopy.access_list = host.access_list; @@ -209,11 +246,16 @@ const internalNginx = { * @param {Object} host * @returns {Promise} */ - generateConfig: (host_type, host_row) => { + generateConfig: async (host_type, host_row) => { // Prevent modifying the original object: const host = JSON.parse(JSON.stringify(host_row)); const nice_host_type = internalNginx.getFileFriendlyHostType(host_type); + await internalNginx.loadRenderRelations(nice_host_type, host); + + // Values are written into the config as-is; refuse any that could add directives + assertHostRenderable(nice_host_type, host); + debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2)); const renderEngine = utils.getRenderEngine(); @@ -276,29 +318,79 @@ const internalNginx = { // Set the IPv6 setting for the host host.ipv6 = internalNginx.ipv6Enabled(); - locationsPromise.then(() => { - renderEngine - .parseAndRender(template, host) - .then((config_text) => { - fs.writeFileSync(filename, config_text, { encoding: "utf8" }); - debug(logger, "Wrote config:", filename, config_text); + locationsPromise + .then(() => { + renderEngine + .parseAndRender(template, host) + .then((config_text) => { + fs.writeFileSync(filename, config_text, { encoding: "utf8" }); + debug(logger, "Wrote config:", filename, config_text); - // Restore locations array - host.locations = origLocations; + // Restore locations array + host.locations = origLocations; - resolve(true); - }) - .catch((err) => { - debug(logger, `Could not write ${filename}:`, err.message); - reject(new errs.ConfigurationError(err.message)); - }); - }).catch((err) => { - debug(logger, `Could not render locations for ${filename}:`, err.message); - reject(new errs.ConfigurationError(err.message)); - }); + resolve(true); + }) + .catch((err) => { + debug(logger, `Could not write ${filename}:`, err.message); + reject(new errs.ConfigurationError(err.message)); + }); + }) + .catch((err) => { + debug(logger, `Could not render locations for ${filename}:`, err.message); + reject(new errs.ConfigurationError(err.message)); + }); }); }, + /** + * The templates rely on the certificate and access list relations being loaded. + * Not every caller fetches them (or fetches them fully), and when they're missing + * the config silently renders without SSL or without any access rules (#5919). + * Load anything that is referenced but not loaded onto the given host object. + * + * @param {String} host_type + * @param {Object} host + * @returns {Promise} + */ + loadRenderRelations: async (host_type, host) => { + if (host.certificate_id > 0 && host.certificate?.id !== host.certificate_id) { + const certificate = await certificateModel + .query() + .where("is_deleted", 0) + .andWhere("id", host.certificate_id) + .first(); + host.certificate = certificate ? JSON.parse(JSON.stringify(certificate)) : null; + } + + if (host_type === "proxy_host" && host.access_list_id > 0) { + if ( + host.access_list?.id !== host.access_list_id || + !Array.isArray(host.access_list.clients) || + !Array.isArray(host.access_list.items) + ) { + const accessList = await accessListModel + .query() + .where("is_deleted", 0) + .andWhere("id", host.access_list_id) + .withGraphFetched("[clients,items]") + .first(); + host.access_list = accessList ? JSON.parse(JSON.stringify(accessList)) : null; + } + + if (!host.access_list) { + // _access.conf renders "deny all" when the list is missing, so fail closed + logger.error( + `Proxy Host #${host.id} references Access List #${host.access_list_id} which does not exist, all access will be denied`, + ); + } else if (!host.access_list.clients.length && !host.access_list.items.length) { + logger.warn( + `Proxy Host #${host.id} uses Access List #${host.access_list_id} which has no clients or authorizations, no access restrictions will apply`, + ); + } + } + }, + /** * This generates a temporary nginx config listening on port 80 for the domain names listed * in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt @@ -311,6 +403,15 @@ const internalNginx = { debug(logger, "Generating LetsEncrypt Request Config:", certificate); const renderEngine = utils.getRenderEngine(); + const unsafe = (certificate.domain_names || []).find((name) => !isTokenSafe(name)); + if (typeof unsafe !== "undefined") { + return Promise.reject( + unsafeValueError( + `Certificate #${certificate.id}: domain name ${JSON.stringify(unsafe)} is not allowed`, + ), + ); + } + return new Promise((resolve, reject) => { let template = null; const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`; diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index eac0c148a..4682a94fa 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -2,7 +2,7 @@ import _ from "lodash"; import errs from "../lib/error.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import utils from "../lib/utils.js"; -import proxyHostModel from "../models/proxy_host.js"; +import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; import internalHost from "./host.js"; @@ -57,6 +57,8 @@ const internalProxyHost = { thisData.advanced_config = ""; } + internalHost.assertAdvancedConfigAllowed(access, thisData); + return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); }) .then((row) => { @@ -83,28 +85,28 @@ const internalProxyHost = { expand: ["certificate", "owner", "access_list.[clients,items]"], }); }) - .then(async (row) => { - // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => { - return row; - }); - }) - .then((row) => { - // Audit log - thisData.meta = _.assign({}, thisData.meta || {}, row.meta); - - // Add to audit log - return internalAuditLog - .add(access, { - action: "created", - object_type: "proxy-host", - object_id: row.id, - meta: thisData, - }) - .then(() => { + .then(async (row) => { + // Configure nginx + return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => { return row; }); - }); + }) + .then((row) => { + // Audit log + thisData.meta = _.assign({}, thisData.meta || {}, row.meta); + + // Add to audit log + return internalAuditLog + .add(access, { + action: "created", + object_type: "proxy-host", + object_id: row.id, + meta: thisData, + }) + .then(() => { + return row; + }); + }); }, /** @@ -155,6 +157,9 @@ const internalProxyHost = { ); } + // Before the certificate is created, so a refused request doesn't leave one behind + internalHost.assertAdvancedConfigAllowed(access, thisData, row); + if (createCertificate) { return internalCertificate .createQuickCertificate(access, { @@ -202,24 +207,24 @@ const internalProxyHost = { }); }); }) - .then(() => { - return internalProxyHost - .get(access, { - id: thisData.id, - expand: ["owner", "certificate", "access_list.[clients,items]"], - }) - .then(async (row) => { - if (!row.enabled) { - // No need to add nginx config if host is disabled - return row; - } - // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { - row.meta = new_meta; - return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); + .then(() => { + return internalProxyHost + .get(access, { + id: thisData.id, + expand: ["owner", "certificate", "access_list.[clients,items]"], + }) + .then(async (row) => { + if (!row.enabled) { + // No need to add nginx config if host is disabled + return row; + } + // Configure nginx + return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { + row.meta = new_meta; + return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); + }); }); - }); - }); + }); }, /** @@ -243,7 +248,7 @@ const internalProxyHost = { .first(); if (access_data.permission_visibility !== "all") { - query.andWhere("owner_user_id", access.token.getUserId(1)); + scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta); } if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { @@ -323,39 +328,36 @@ const internalProxyHost = { .then(() => { return internalProxyHost.get(access, { id: data.id, - expand: ["certificate", "owner", "access_list"], + expand: ["certificate", "owner", "access_list.[clients,items]"], }); }) - .then(async (row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - if (row.enabled) { - throw new errs.ValidationError("Host is already enabled"); - } + .then(async (row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + if (row.enabled) { + throw new errs.ValidationError("Host is already enabled"); + } - row.enabled = 1; + row.enabled = 1; - await proxyHostModel - .query() - .where("id", row.id) - .patch({ + await proxyHostModel.query().where("id", row.id).patch({ enabled: 1, }); - // Configure nginx - await internalNginx.configure(proxyHostModel, "proxy_host", row); + // Configure nginx + await internalNginx.configure(proxyHostModel, "proxy_host", row); - // Add to audit log - await internalAuditLog.add(access, { - action: "enabled", - object_type: "proxy-host", - object_id: row.id, - meta: _.omit(row, omissions()), + // Add to audit log + await internalAuditLog.add(access, { + action: "enabled", + object_type: "proxy-host", + object_id: row.id, + meta: _.omit(row, omissions()), + }); + + return true; }); - - return true; - }); }, /** @@ -427,7 +429,7 @@ const internalProxyHost = { .orderBy(castJsonIfNeed("domain_names"), "ASC"); if (accessData.permission_visibility !== "all") { - query.andWhere("owner_user_id", access.token.getUserId(1)); + scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta); } // Query is used for searching @@ -453,18 +455,18 @@ const internalProxyHost = { * * @param {Number} user_id * @param {String} visibility + * @param {Object} [permissionsMeta] the user's permissions meta, as already loaded by access * @returns {Promise} */ - getCount: (user_id, visibility) => { + getCount: async (user_id, visibility, permissionsMeta) => { const query = proxyHostModel.query().count("id as count").where("is_deleted", 0); if (visibility !== "all") { - query.andWhere("owner_user_id", user_id); + scopeToOwnerOrAllowed(query, user_id, permissionsMeta); } - return query.first().then((row) => { - return Number.parseInt(row.count, 10); - }); + const row = await query.first(); + return Number.parseInt(row.count, 10); }, }; diff --git a/backend/internal/redirection-host.js b/backend/internal/redirection-host.js index 542439fd3..f5945a6b8 100644 --- a/backend/internal/redirection-host.js +++ b/backend/internal/redirection-host.js @@ -53,10 +53,12 @@ const internalRedirectionHost = { // Fix for db field not having a default value // for this optional field. - if (typeof data.advanced_config === "undefined") { - data.advanced_config = ""; + if (typeof thisData.advanced_config === "undefined") { + thisData.advanced_config = ""; } + internalHost.assertAdvancedConfigAllowed(access, thisData); + return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); }) .then((row) => { @@ -155,6 +157,9 @@ const internalRedirectionHost = { ); } + // Before the certificate is created, so a refused request doesn't leave one behind + internalHost.assertAdvancedConfigAllowed(access, thisData, row); + if (createCertificate) { return internalCertificate .createQuickCertificate(access, { diff --git a/backend/internal/report.js b/backend/internal/report.js index 59f13fe69..547af3ef3 100644 --- a/backend/internal/report.js +++ b/backend/internal/report.js @@ -15,7 +15,11 @@ const internalReport = { const userId = access.token.getUserId(1); const promises = [ - internalProxyHost.getCount(userId, access_data.permission_visibility), + internalProxyHost.getCount( + userId, + access_data.permission_visibility, + access.getPermissions?.()?.meta, + ), internalRedirectionHost.getCount(userId, access_data.permission_visibility), internalStream.getCount(userId, access_data.permission_visibility), internalDeadHost.getCount(userId, access_data.permission_visibility), diff --git a/backend/internal/token.js b/backend/internal/token.js index 67255b213..d3a178d4f 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -34,18 +34,20 @@ export default { .andWhere("is_disabled", 0) .first(); - if (!user) { - throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); - } + // An unknown, disabled or passwordless account goes through the same queries and the same password check + // as a real one, and fails with the same error: answering any sooner or any differently would tell anyone + // trying addresses which ones have an account. There is no user 0, so its lookup finds nothing. An empty + // secret, which creating a user through the API can store, is no hash: bcrypt would reject it at once. + const auth = await authModel + .query() + .where("user_id", "=", user?.id ?? 0) + .where("type", "=", "password") + .first(); - const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first(); - - if (!auth) { - throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); - } - - const valid = await auth.verifyPassword(data.secret); - if (!valid) { + const valid = auth?.secret + ? await auth.verifyPassword(data.secret) + : await authModel.verifyDummyPassword(data.secret); + if (!user || !valid) { throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N); } diff --git a/backend/internal/user.js b/backend/internal/user.js index 8dd86f461..0c0313b44 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -3,6 +3,7 @@ import _ from "lodash"; import errs from "../lib/error.js"; import utils from "../lib/utils.js"; import authModel from "../models/auth.js"; +import proxyHostModel from "../models/proxy_host.js"; import userModel from "../models/user.js"; import userPermissionModel from "../models/user_permission.js"; import internalAuditLog from "./audit-log.js"; @@ -444,7 +445,7 @@ const internalUser = { return internalUser.get(access, { id: data.id }); }) .then((user) => { - if (user.id !== data.id) { + if (Number(user.id) !== Number(data.id)) { // Sanity check that something crazy hasn't happened throw new errs.InternalValidationError( `User could not be updated, IDs do not match: ${user.id} !== ${data.id}`, @@ -454,21 +455,23 @@ const internalUser = { return user; }) .then((user) => { + const permData = _.omit(data, ["id"]); // Get perms row, patch if it exists return userPermissionModel .query() .where("user_id", user.id) .first() - .then((existing_auth) => { + .then(async (existing_auth) => { + permData.meta = await internalUser.normalisePermissionMeta(existing_auth, permData); if (existing_auth) { // patch return userPermissionModel .query() .where("user_id", user.id) - .patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, data)); + .patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, permData)); } // insert - return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data)); + return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData)); }) .then((permissions) => { // Add to Audit Log @@ -488,6 +491,36 @@ const internalUser = { }); }, + /** + * The permission meta to store. Proxy host grants (meta.proxy_host_ids) only apply while + * visibility is "user" and proxy hosts are not hidden, so they're cleared otherwise; kept, + * they would silently come back when proxy hosts are shown again. Ids of hosts that no + * longer exist are dropped. `permData` may be partial, so the stored values fill the gaps. + * + * @param {Object} [existing] the stored user_permission row + * @param {Object} permData incoming permission fields + * @returns {Promise} + */ + normalisePermissionMeta: async (existing, permData) => { + const effective = _.assign( + { visibility: "user", proxy_hosts: "hidden" }, + existing || {}, + _.omit(permData, ["meta"]), + ); + const meta = _.assign({}, existing?.meta || {}, permData.meta || {}); + const requested = Array.isArray(meta.proxy_host_ids) ? _.uniq(meta.proxy_host_ids) : []; + + if (effective.visibility !== "user" || effective.proxy_hosts === "hidden" || !requested.length) { + meta.proxy_host_ids = []; + return meta; + } + + const rows = await proxyHostModel.query().select("id").where("is_deleted", 0).whereIn("id", requested); + const found = new Set(rows.map((r) => r.id)); + meta.proxy_host_ids = requested.filter((id) => found.has(id)); + return meta; + }, + /** * @param {Access} access * @param {Object} data diff --git a/backend/lib/access.js b/backend/lib/access.js index d119a4f92..c38213b33 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -13,7 +13,7 @@ import Ajv from "ajv/dist/2020.js"; import _ from "lodash"; import { access as logger } from "../logger.js"; import authModel from "../models/auth.js"; -import proxyHostModel from "../models/proxy_host.js"; +import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js"; import TokenModel from "../models/token.js"; import userModel from "../models/user.js"; import permsSchema from "./access/permissions.json" with { type: "json" }; @@ -139,7 +139,7 @@ export default function (tokenString) { const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0); if (permissions.visibility === "user") { - query.andWhere("owner_user_id", tokenUserId); + scopeToOwnerOrAllowed(query, tokenUserId, permissions.meta); } const rows = await query; @@ -213,6 +213,11 @@ export default function (tokenString) { return { token: Token, + getPermissions: () => permissions, + getRoles: () => userRoles, + hasRole: (role) => userRoles.includes(role), + isAdmin: () => userRoles.includes("admin") || Token.hasScope("admin"), + /** * * @param {Boolean} [allowInternal] @@ -220,7 +225,8 @@ export default function (tokenString) { */ load: async (allowInternal) => { if (tokenString) { - return await Token.load(tokenString); + await this.init(); + return tokenData; } allowInternalAccess = allowInternal; return allowInternal || null; @@ -255,6 +261,7 @@ export default function (tokenString) { permission_streams: permissions.streams, permission_access_lists: permissions.access_lists, permission_certificates: permissions.certificates, + permission_meta: permissions.meta, }, }; diff --git a/backend/lib/express/rate-limit.js b/backend/lib/express/rate-limit.js new file mode 100644 index 000000000..7512f7485 --- /dev/null +++ b/backend/lib/express/rate-limit.js @@ -0,0 +1,155 @@ +import crypto from "node:crypto"; +import { isCI } from "../config.js"; + +/** + * In-memory fixed window rate limiter for authentication endpoints + */ + +// Keys longer than this are hashed, so a client can't make the store hold arbitrarily large +// strings (keys can include request body values such as the login identity). +const MAX_KEY_LENGTH = 128; +// Entries per limiter. A flood of unique keys sweeps expired entries, then evicts the oldest, +// instead of growing the store until the periodic cleanup runs. +const MAX_STORE_SIZE = 50000; + +// One store per limiter, so separate endpoints never share buckets +const stores = new Set(); + +// Clean up old entries every 2 minutes +const cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const store of stores) { + for (const [key, record] of store.entries()) { + if (now - record.startTime > record.windowMs * 2) { + store.delete(key); + } + } + } +}, 120000); +cleanupTimer.unref(); + +// Generated at container start (prepare/45-admin-port.sh) and sent by the admin nginx as +// X-NPM-Peer-Secret alongside X-NPM-Peer-Addr. Unset (development, or a mounted production.conf) +// means the header is never trusted. +const peerSecret = Buffer.from(process.env.NPM_PEER_SECRET || ""); + +const hasPeerSecret = (value) => { + if (!peerSecret.length || typeof value !== "string") { + return false; + } + const given = Buffer.from(value); + return given.length === peerSecret.length && crypto.timingSafeEqual(given, peerSecret); +}; + +/** + * The address to rate limit on: the TCP peer of the admin nginx, which it passes as + * X-NPM-Peer-Addr ($realip_remote_addr). That header is only honoured from a loopback socket + * carrying the startup secret, so it can only have come from the admin nginx: a proxy host or + * stream forwarding to the backend port is also a loopback peer, but can't know the secret. + * The realip-resolved client address is deliberately not used: nginx trusts X-Real-IP from + * private ranges, so a client on the LAN or Docker network could pick a new address, and a fresh + * bucket, on every request. Behind another proxy (including an NPM proxy host in front of the + * admin UI) this resolves to that proxy's address. + * + * @param {Object} req + * @returns {String} + */ +export const getClientIp = (req) => { + const socketAddr = req.socket?.remoteAddress || "unknown"; + const isLoopback = socketAddr === "127.0.0.1" || socketAddr === "::1" || socketAddr === "::ffff:127.0.0.1"; + const trusted = isLoopback && hasPeerSecret(req.headers["x-npm-peer-secret"]); + const peerAddr = trusted ? req.headers["x-npm-peer-addr"]?.trim() : null; + return peerAddr || socketAddr; +}; + +const makeRoom = (store, now) => { + for (const [k, r] of store.entries()) { + if (now - r.startTime > r.windowMs) { + store.delete(k); + } + } + // Maps iterate in insertion order, so this drops the oldest windows first + for (const k of store.keys()) { + if (store.size < MAX_STORE_SIZE) { + break; + } + store.delete(k); + } +}; + +const boundedKey = (key) => + key.length > MAX_KEY_LENGTH ? `sha256:${crypto.createHash("sha256").update(key).digest("hex")}` : key; + +/** + * @param {Object} [options] + * @param {Number} [options.windowMs] + * @param {Number} [options.max] + * @param {String} [options.message] + * @param {Boolean} [options.skipSuccessfulRequests] + * @param {Function} [options.keyGenerator] (req, clientIp) => String. Defaults to the client IP. + * Including the target account in the key stops every + * user being locked out when many clients share an IP + * (e.g. the admin UI behind another reverse proxy). + */ +export default function createRateLimiter(options = {}) { + const windowMs = options.windowMs || 60 * 1000; // 1 minute default + const max = options.max || 10; // 10 requests default + const message = options.message || "Too many attempts from this IP, please try again later."; + const skipSuccessfulRequests = options.skipSuccessfulRequests !== false; + const keyGenerator = options.keyGenerator || ((_req, clientIp) => clientIp); + + const store = new Map(); + stores.add(store); + + return (req, res, next) => { + // Bypass rate limiting in CI, test environments, or when explicitly disabled + if ( + isCI() || + process.env.CI === "true" || + process.env.NODE_ENV === "test" || + process.env.DISABLE_RATE_LIMIT === "true" + ) { + return next(); + } + + const key = boundedKey(String(keyGenerator(req, getClientIp(req)))); + + const now = Date.now(); + let record = store.get(key); + + if (!record || now - record.startTime > windowMs) { + record = { + count: 0, + startTime: now, + windowMs, + }; + if (!store.has(key) && store.size >= MAX_STORE_SIZE) { + makeRoom(store, now); + } + store.set(key, record); + } + + record.count++; + + if (skipSuccessfulRequests) { + const currentRecord = record; + res.on("finish", () => { + if (res.statusCode < 400 && currentRecord.count > 0) { + currentRecord.count--; + } + }); + } + + if (record.count > max) { + res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000)); + return res.status(429).json({ + error: { + code: 429, + message, + }, + }); + } + + next(); + }; +} diff --git a/backend/lib/nginx-values.js b/backend/lib/nginx-values.js new file mode 100644 index 000000000..0f5382623 --- /dev/null +++ b/backend/lib/nginx-values.js @@ -0,0 +1,134 @@ +import net from "node:net"; +import errs from "./error.js"; + +/** + * Rules for user-supplied values that the templates write into nginx config as-is. + * + * Anything that ends or changes an nginx token (whitespace, ";", "{", "}", quotes, "#" which + * starts a comment, and "\" which escapes the next character) would let the value add its own + * directives, which only admins may do (advanced_config). The API schema rejects these values + * with the same patterns (a test keeps the two in step). generateConfig() checks stored values + * again, so rows saved before these rules can't add directives either. That check is only about + * nginx structure, not format: a stored value in a format the API no longer accepts, but which + * can't add directives, still renders. + * + * Patterns are strings so they can be compared with the JSON schema; compile them with the "u" + * flag, as ajv does. + */ + +// One character that can't end or change an unquoted nginx token +const TOKEN_CHAR = String.raw`[^\s\p{Cc};{}"'#\\]`; +// Host part of a forward host: also no "$" (no variables in the upstream address) and no "/" +const HOST_CHARS = String.raw`[^\s\p{Cc};{}"'#\\$/]+`; +// The existing domain name rule ("a.b.c", no "*", no empty labels), written so it can't +// backtrack exponentially +const DOMAIN = String.raw`[^.*]+(?:\.[^.*]+)*\.?[^.]`; + +export const patterns = { + // server_name entries. The old character blacklist plus whitespace and control characters. + domainName: "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$", + // proxy_host forward_host and location forward_host: a host, optionally followed by a path + // (locations split "host/path" at render time) + forwardHost: String.raw`^${HOST_CHARS}(?:/${TOKEN_CHAR}*)?$`, + // location forward_path, appended straight after the port + forwardPath: String.raw`^${TOKEN_CHAR}*$`, + // location path: an optional modifier, then one token, either unquoted (a "\" must escape a + // visible character, so it can't swallow the space before "{") or double quoted. A modifier + // on its own isn't a location. + locationPath: String.raw`^(?!(?:=|~\*?|\^~)$)(?:(?:=|~\*?|\^~) +)?(?:"(?:[^"\\\p{Cc}]|\\[^\p{Cc}])+"|(?:[^\s\p{Cc};{}"'#\\]|\\[^\s\p{Cc}])+)$`, + // redirection forward_domain_name, e.g. "example.com:8443/path" + redirectDomain: String.raw`^(?=${TOKEN_CHAR}+$)${DOMAIN}$`, + // stream forwarding_host when it is a domain name (IPs are matched separately) + streamDomain: String.raw`^(?=${HOST_CHARS}$)${DOMAIN}$`, + // access list client address: IPv6 with an optional prefix. No zone id ("%eth0"): nginx's + // allow/deny rejects them, and the old "%.+" let anything through, including "; allow all" + clientIpv6: String.raw`^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$`, + // htpasswd user name: a ":" or a line break would corrupt the file + accessUsername: String.raw`^[^:\p{Cc}]+$`, +}; + +const re = Object.fromEntries(Object.entries(patterns).map(([k, v]) => [k, new RegExp(v, "u")])); +const unsafeToken = /[\s\p{Cc};{}"'#\\]/u; +const ipv4WithPrefix = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/; + +export const isValid = (name, value) => typeof value === "string" && re[name].test(value); + +export const isValidClientAddress = (value) => + typeof value === "string" && (value === "all" || ipv4WithPrefix.test(value) || re.clientIpv6.test(value)); + +export const isValidStreamHost = (value) => + typeof value === "string" && (net.isIP(value) !== 0 || re.streamDomain.test(value)); + +/** + * Thrown when a stored value can't be written into nginx config safely + * + * @param {String} message + */ +export const unsafeValueError = (message) => { + const err = new errs.ConfigurationError(message); + err.unsafeNginxValue = true; + return err; +}; + +const describe = (value) => JSON.stringify(String(value)).slice(0, 80); + +/** + * @param {String} label e.g. "Proxy Host #3" + * @param {Object} [accessList] + * @throws {ConfigurationError} + */ +export const assertAccessListRenderable = (label, accessList) => { + for (const client of accessList?.clients || []) { + // "address" is rendered unquoted ("allow
;") + if (typeof client.address !== "string" || unsafeToken.test(client.address)) { + throw unsafeValueError(`${label}: access list client address ${describe(client.address)} is not allowed`); + } + } +}; + +// Can be written into config without ending or changing a token +export const isTokenSafe = (value) => typeof value === "string" && value.length > 0 && !unsafeToken.test(value); + +/** + * Checks every user-supplied value the host's template renders. Call before rendering. + * + * @param {String} hostType proxy_host, redirection_host, dead_host, stream or default + * @param {Object} host with access_list loaded, for proxy hosts + * @throws {ConfigurationError} + */ +export const assertHostRenderable = (hostType, host) => { + const label = `${hostType.replace(/_/g, " ")} #${host.id}`; + const check = (ok, what, value) => { + if (!ok) { + throw unsafeValueError(`${label}: ${what} ${describe(value)} is not allowed in nginx config`); + } + }; + + if (hostType !== "stream" && hostType !== "default") { + for (const name of host.domain_names || []) { + check(isTokenSafe(name), "domain name", name); + } + } + + if (hostType === "proxy_host") { + check(isTokenSafe(host.forward_host), "forward host", host.forward_host); + assertAccessListRenderable(label, host.access_list); + for (const loc of host.locations || []) { + // The path is several tokens (modifier, then a quoted or unquoted URI): its rule is the + // structural one + check(isValid("locationPath", loc.path), "location path", loc.path); + check(isTokenSafe(loc.forward_host), "location forward host", loc.forward_host); + if (loc.forward_path) { + check(isTokenSafe(loc.forward_path), "location forward path", loc.forward_path); + } + } + } + + if (hostType === "redirection_host") { + check(isTokenSafe(host.forward_domain_name), "forward domain", host.forward_domain_name); + } + + if (hostType === "stream") { + check(isTokenSafe(host.forwarding_host), "forwarding host", host.forwarding_host); + } +}; diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js index 0597ef635..cc25a4f35 100644 --- a/backend/lib/validator/api.js +++ b/backend/lib/validator/api.js @@ -35,6 +35,13 @@ const apiValidator = async (schema, payload /*, description*/) => { return payload; } + // Keep ajv's wording, which clients may match on, but not the regex it quotes: some are long + // and mean nothing to a user + for (const e of validate.errors || []) { + if (e.keyword === "pattern") { + e.message = "must match pattern"; + } + } const message = ajv.errorsText(validate.errors); const err = new errs.ValidationError(message); err.debug = { validationErrors: validate.errors, payload }; diff --git a/backend/migrations/20260906220000_user_permission_meta.js b/backend/migrations/20260906220000_user_permission_meta.js new file mode 100644 index 000000000..1573c77d8 --- /dev/null +++ b/backend/migrations/20260906220000_user_permission_meta.js @@ -0,0 +1,41 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "user_permission_meta"; + +/** + * Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .alterTable("user_permission", (table) => { + table.json("meta").nullable(); + }) + .then(() => { + logger.info(`[${migrateName}] user_permission Table altered`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .alterTable("user_permission", (table) => { + table.dropColumn("meta"); + }) + .then(() => { + logger.info(`[${migrateName}] user_permission Table altered`); + }); +}; + +export { up, down }; diff --git a/backend/models/auth.js b/backend/models/auth.js index e8af582d5..c6228feeb 100644 --- a/backend/models/auth.js +++ b/backend/models/auth.js @@ -1,6 +1,7 @@ // Objection Docs: // http://vincit.github.io/objection.js/ +import crypto from "node:crypto"; import bcrypt from "bcrypt"; import { Model } from "objection"; import db from "../db.js"; @@ -11,10 +12,15 @@ import User from "./user.js"; Model.knex(db()); const boolFields = ["is_deleted"]; +const saltRounds = 13; + +// A hash of a random value, for a login that names an account with no password to check it against. Hashed +// with the same cost as real passwords, and made at startup so the first such login takes no longer either. +const dummyHash = bcrypt.hash(crypto.randomBytes(32).toString("hex"), saltRounds); function encryptPassword() { if (this.type === "password" && this.secret) { - return bcrypt.hash(this.secret, 13).then((hash) => { + return bcrypt.hash(this.secret, saltRounds).then((hash) => { this.secret = hash; }); } @@ -60,6 +66,18 @@ class Auth extends Model { return bcrypt.compare(password, this.secret); } + /** + * Take as long as verifyPassword does, then fail. bcrypt is nearly all of the time a login takes, so a login + * that skipped it for an unknown account would answer far sooner, and say which accounts exist. + * + * @param {String} password + * @returns {Promise} + */ + static async verifyDummyPassword(password) { + await bcrypt.compare(password, await dummyHash); + return false; + } + static get name() { return "Auth"; } diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da935..d16ddc912 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -124,4 +124,23 @@ class ProxyHost extends Model { } } +/** + * Restricts a proxy host query to hosts the user owns or has been granted by id + * (user permission meta.proxy_host_ids). + * + * @param {Object} query objection query on proxy_host + * @param {Number} userId + * @param {Object} [permissionMeta] the user's permission meta + * @returns {Object} the query + */ +export const scopeToOwnerOrAllowed = (query, userId, permissionMeta) => { + const allowedIds = permissionMeta?.proxy_host_ids || []; + return query.andWhere((builder) => { + builder.where("owner_user_id", userId); + if (allowedIds.length > 0) { + builder.orWhereIn("id", allowedIds); + } + }); +}; + export default ProxyHost; diff --git a/backend/models/user_permission.js b/backend/models/user_permission.js index cc6eac2cc..0ad444c9c 100644 --- a/backend/models/user_permission.js +++ b/backend/models/user_permission.js @@ -24,6 +24,10 @@ class UserPermission extends Model { static get tableName() { return "user_permission"; } + + static get jsonAttributes() { + return ["meta"]; + } } export default UserPermission; diff --git a/backend/package.json b/backend/package.json index b8e0ed9fc..0a65fbe70 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,42 +8,43 @@ "type": "module", "scripts": { "lint": "biome lint", + "test": "node --test test/", "prettier": "biome format --write .", "validate-schema": "node validate-schema.js", "regenerate-config": "node scripts/regenerate-config" }, "dependencies": { - "@apidevtools/json-schema-ref-parser": "^16.0.2", + "@apidevtools/json-schema-ref-parser": "^16.0.6", "ajv": "^8.20.0", "archiver": "^8.0.0", "batchflow": "^0.4.0", "bcrypt": "^6.0.0", "better-sqlite3": "^13.0.3", - "body-parser": "^2.3.0", - "chalk": "5.6.2", + "body-parser": "^2.4.0", + "chalk": "6.0.1", "compression": "^1.8.2", - "express": "^5.2.1", - "express-fileupload": "^1.5.2", + "express": "^5.3.0", + "express-fileupload": "^1.6.0", "gravatar": "^1.8.2", "jsonwebtoken": "^9.0.3", "knex": "3.3.0", - "liquidjs": "10.29.0", + "liquidjs": "10.30.1", "lodash": "^4.18.1", "moment": "^2.31.0", - "mysql2": "^3.24.4", + "mysql2": "^3.24.6", "node-rsa": "^2.0.0", - "objection": "3.1.5", - "otplib": "^13.5.0", + "objection": "4.4.3", + "otplib": "^13.6.0", "path": "^0.12.7", - "pg": "^8.23.0", + "pg": "^8.23.1", "proxy-agent": "^8.0.2", "signale": "1.4.0", "sqlite3": "^6.0.1", "temp-write": "^6.0.1" }, "devDependencies": { - "@apidevtools/swagger-parser": "^13.0.0", - "@biomejs/biome": "^2.5.10", + "@apidevtools/swagger-parser": "^13.1.0", + "@biomejs/biome": "^2.6.6", "nodemon": "^3.1.14" }, "signale": { diff --git a/backend/routes/logs.js b/backend/routes/logs.js index 9088507b3..09841b3bd 100644 --- a/backend/routes/logs.js +++ b/backend/routes/logs.js @@ -62,7 +62,10 @@ router enum: ["system", "letsencrypt", "host"], }, host_type: { - anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }], + anyOf: [ + { type: "null" }, + { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }, + ], }, host_id: { anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }], @@ -76,7 +79,10 @@ router level: { anyOf: [ { type: "null" }, - { type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] }, + { + type: "string", + enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"], + }, ], }, search: { diff --git a/backend/routes/nginx/certificates.js b/backend/routes/nginx/certificates.js index dea809423..90f279c37 100644 --- a/backend/routes/nginx/certificates.js +++ b/backend/routes/nginx/certificates.js @@ -161,7 +161,7 @@ router } try { - const result = await internalCertificate.validate({ + const result = await internalCertificate.validate(res.locals.access, { files: req.files, }); res.status(200).send(result); diff --git a/backend/routes/tokens.js b/backend/routes/tokens.js index 8a6a1bc0f..f56daa058 100644 --- a/backend/routes/tokens.js +++ b/backend/routes/tokens.js @@ -1,10 +1,56 @@ import express from "express"; import internalToken from "../internal/token.js"; import jwtdecode from "../lib/express/jwt-decode.js"; +import createRateLimiter from "../lib/express/rate-limit.js"; import apiValidator from "../lib/validator/api.js"; import { debug, express as logger } from "../logger.js"; import { getValidationSchema } from "../schema/index.js"; +// Two limits apply to logins. Per IP + target account: when the admin UI sits behind another +// proxy every client can resolve to the same IP, and an IP-only bucket at this size would let +// anyone lock out every user with a handful of bad logins. +const tokenRateLimiter = createRateLimiter({ + windowMs: 60 * 1000, + max: 10, + message: "Too many login attempts. Please try again in 1 minute.", + skipSuccessfulRequests: true, + keyGenerator: (req, clientIp) => { + const identity = typeof req.body?.identity === "string" ? req.body.identity.trim().toLowerCase() : ""; + return `${clientIp}|${identity}`; + }, +}); + +// And per IP across all accounts, so one address can't spray passwords over many accounts at +// 10 a minute each. Only failures count. Behind a shared proxy this cap is shared by everyone +// using it, which is why it's higher than the per-account one. +const tokenIpRateLimiter = createRateLimiter({ + windowMs: 60 * 1000, + max: 30, + message: "Too many login attempts. Please try again in 1 minute.", + skipSuccessfulRequests: true, +}); + +// Keyed on the user the challenge belongs to, not the challenge token itself, so fetching +// fresh challenge tokens doesn't reset the code-guessing budget. The payload is read without +// verifying the signature; verify2FA does that. A forged token only affects buckets for the +// sender's own IP. +const challengeUserId = (token) => { + try { + const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString()); + return String(payload?.attrs?.id ?? ""); + } catch { + return ""; + } +}; + +const twoFactorRateLimiter = createRateLimiter({ + windowMs: 60 * 1000, + max: 10, + message: "Too many login attempts. Please try again in 1 minute.", + skipSuccessfulRequests: true, + keyGenerator: (req, clientIp) => `${clientIp}|${challengeUserId(req.body?.challenge_token)}`, +}); + const router = express.Router({ caseSensitive: true, strict: true, @@ -42,7 +88,7 @@ router * * Create a new Token */ - .post(async (req, res, next) => { + .post(tokenIpRateLimiter, tokenRateLimiter, async (req, res, next) => { try { const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body); const result = await internalToken.getTokenFromEmail(data); @@ -64,7 +110,7 @@ router * * Verify 2FA code and get full token */ - .post(async (req, res, next) => { + .post(twoFactorRateLimiter, async (req, res, next) => { try { const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body); const result = await internalToken.verify2FA(challenge_token, code); diff --git a/backend/routes/users.js b/backend/routes/users.js index 3f972223b..2cdb4101c 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -260,7 +260,7 @@ router .put(async (req, res, next) => { try { const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body); - payload.id = req.params.user_id; + payload.id = Number.parseInt(req.params.user_id, 10); const result = await internalUser.setPermissions(res.locals.access, payload); res.status(200).send(result); } catch (err) { diff --git a/backend/schema/common.json b/backend/schema/common.json index 00b06e005..3b12bd143 100644 --- a/backend/schema/common.json +++ b/backend/schema/common.json @@ -84,7 +84,7 @@ "uniqueItems": true, "items": { "type": "string", - "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$" + "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$" }, "example": ["example.com", "www.example.com"] }, @@ -147,7 +147,7 @@ }, { "type": "string", - "pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$" + "pattern": "^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$" }, { "type": "string", @@ -164,7 +164,8 @@ "properties": { "username": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^[^:\\p{Cc}]+$" }, "password": { "type": "string" diff --git a/backend/schema/components/certificate-object.json b/backend/schema/components/certificate-object.json index 80cd92bef..c86a54da7 100644 --- a/backend/schema/components/certificate-object.json +++ b/backend/schema/components/certificate-object.json @@ -31,7 +31,7 @@ "uniqueItems": true, "items": { "type": "string", - "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$" + "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$" }, "example": ["example.com", "www.example.com"] }, diff --git a/backend/schema/components/permission-object.json b/backend/schema/components/permission-object.json index cae9d26c0..c49e6cba9 100644 --- a/backend/schema/components/permission-object.json +++ b/backend/schema/components/permission-object.json @@ -1,6 +1,7 @@ { "type": "object", "minProperties": 1, + "additionalProperties": false, "properties": { "visibility": { "type": "string", @@ -43,6 +44,25 @@ "description": "Certificates Permissions", "enum": ["hidden", "view", "manage"], "example": "hidden" + }, + "meta": { + "type": "object", + "description": "Permission metadata", + "example": { + "proxy_host_ids": [1] + }, + "properties": { + "proxy_host_ids": { + "type": "array", + "description": "Allowed Proxy Host IDs", + "example": [1], + "items": { + "type": "integer", + "description": "Proxy Host ID", + "example": 1 + } + } + } } } } diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 90e6e23bb..dcef18cfb 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -45,6 +45,7 @@ "type": "string", "minLength": 1, "maxLength": 255, + "pattern": "^[^\\s\\p{Cc};{}\"'#\\\\$/]+(?:/[^\\s\\p{Cc};{}\"'#\\\\]*)?$", "example": "127.0.0.1" }, "forward_port": { @@ -108,7 +109,8 @@ }, "path": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^(?!(?:=|~\\*?|\\^~)$)(?:(?:=|~\\*?|\\^~) +)?(?:\"(?:[^\"\\\\\\p{Cc}]|\\\\[^\\p{Cc}])+\"|(?:[^\\s\\p{Cc};{}\"'#\\\\]|\\\\[^\\s\\p{Cc}])+)$" }, "forward_scheme": { "$ref": "#/properties/forward_scheme" @@ -120,7 +122,8 @@ "$ref": "#/properties/forward_port" }, "forward_path": { - "type": "string" + "type": "string", + "pattern": "^[^\\s\\p{Cc};{}\"'#\\\\]*$" }, "advanced_config": { "type": "string" diff --git a/backend/schema/components/redirection-host-object.json b/backend/schema/components/redirection-host-object.json index 58169720b..199053019 100644 --- a/backend/schema/components/redirection-host-object.json +++ b/backend/schema/components/redirection-host-object.json @@ -57,7 +57,7 @@ "forward_domain_name": { "description": "Domain Name", "type": "string", - "pattern": "^(?:[^.*]+\\.?)+[^.]$", + "pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$", "example": "jc21.com" }, "preserve_path": { diff --git a/backend/schema/components/stream-object.json b/backend/schema/components/stream-object.json index 3a1cd5210..3b8cd4f55 100644 --- a/backend/schema/components/stream-object.json +++ b/backend/schema/components/stream-object.json @@ -39,7 +39,7 @@ { "description": "Domain Name", "type": "string", - "pattern": "^(?:[^.*]+\\.?)+[^.]$", + "pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\$/]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$", "example": "example.com" }, { diff --git a/backend/schema/paths/nginx/certificates/certID/delete.json b/backend/schema/paths/nginx/certificates/certID/delete.json index a99f619d6..2c38c388c 100644 --- a/backend/schema/paths/nginx/certificates/certID/delete.json +++ b/backend/schema/paths/nginx/certificates/certID/delete.json @@ -35,6 +35,26 @@ } } } + }, + "400": { + "description": "400 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "error": { + "code": 400, + "message": "Certificate is in use by 1 proxy host. Remove it from them before deleting it." + } + } + } + }, + "schema": { + "$ref": "../../../../components/error.json" + } + } + } } } } diff --git a/backend/schema/paths/users/userID/permissions/put.json b/backend/schema/paths/users/userID/permissions/put.json index 764e7466c..0bbef6dfb 100644 --- a/backend/schema/paths/users/userID/permissions/put.json +++ b/backend/schema/paths/users/userID/permissions/put.json @@ -35,7 +35,10 @@ "dead_hosts": "hidden", "proxy_hosts": "manage", "redirection_hosts": "hidden", - "streams": "hidden" + "streams": "hidden", + "meta": { + "proxy_host_ids": [1] + } } } } diff --git a/backend/templates/_access.conf b/backend/templates/_access.conf index 81983f3a3..7ae905472 100644 --- a/backend/templates/_access.conf +++ b/backend/templates/_access.conf @@ -1,4 +1,5 @@ {% if access_list_id > 0 %} + {% if access_list %} {% if access_list.items.length > 0 %} # Authorization auth_basic "Authorization required"; @@ -24,4 +25,8 @@ {% else %} satisfy all; {% endif %} + {% else %} + # Access List #{{ access_list_id }} could not be loaded: deny rather than serve unrestricted + deny all; + {% endif %} {% endif %} diff --git a/backend/templates/_hsts.conf b/backend/templates/_hsts.conf index 26c83ee8b..0e9829009 100644 --- a/backend/templates/_hsts.conf +++ b/backend/templates/_hsts.conf @@ -2,7 +2,11 @@ {% if ssl_forced == 1 or ssl_forced == true %} {% if hsts_enabled == 1 or hsts_enabled == true %} # HSTS (ngx_http_headers_module is required) (63072000 seconds = 2 years) - add_header Strict-Transport-Security $hsts_header always; +{% if hsts_subdomains == 1 or hsts_subdomains == true %} + add_header Strict-Transport-Security $hsts_header_subdomains always; +{% else %} + add_header Strict-Transport-Security $hsts_header_plain always; +{% endif %} {% endif %} {% endif %} {% endif %} diff --git a/backend/templates/_hsts_map.conf b/backend/templates/_hsts_map.conf index 27dd1f8f4..f9f3339ef 100644 --- a/backend/templates/_hsts_map.conf +++ b/backend/templates/_hsts_map.conf @@ -1,3 +1,6 @@ -map $scheme $hsts_header { - https "max-age=63072000;{% if hsts_subdomains == 1 or hsts_subdomains == true -%} includeSubDomains;{% endif %} preload"; +map $scheme $hsts_header_plain { + https "max-age=63072000; preload"; +} +map $scheme $hsts_header_subdomains { + https "max-age=63072000;includeSubDomains; preload"; } \ No newline at end of file diff --git a/backend/templates/_location.conf b/backend/templates/_location.conf index a2ecb166d..550a87e3b 100644 --- a/backend/templates/_location.conf +++ b/backend/templates/_location.conf @@ -1,7 +1,7 @@ location {{ path }} { {{ advanced_config }} - proxy_set_header Host $host; + proxy_set_header Host $forward_host; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; diff --git a/backend/test/nginx-values.test.js b/backend/test/nginx-values.test.js new file mode 100644 index 000000000..a70a986f3 --- /dev/null +++ b/backend/test/nginx-values.test.js @@ -0,0 +1,550 @@ +/** + * Regression tests: values a non-admin can set must never add nginx directives. + * + * Templates write these values into config as-is, so each one is checked three ways: + * - the API rejects injection attempts (real request schemas, real validator) + * - accepted values, rendered through the real templates, produce exactly the same nginx + * statement structure as a plain value (parsed with nginx's tokenizer rules) + * - generateConfig's render-time check refuses unsafe values already stored in the database + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { describe, it } from "node:test"; +import { + assertHostRenderable, + isValid, + isValidClientAddress, + isValidStreamHost, + patterns, +} from "../lib/nginx-values.js"; +import utils from "../lib/utils.js"; +import apiValidator from "../lib/validator/api.js"; +import { getCompiledSchema, getValidationSchema } from "../schema/index.js"; + +const TEMPLATES = new URL("../templates/", import.meta.url); + +// --------------------------------------------------------------------------------------------- +// A tokenizer following ngx_conf_read_token: whitespace separates, ";" ends a statement, "{" +// opens a block, "}" closes one, "#" at the start of a token comments to end of line, quotes +// group, "\" escapes the next character, and "${" doesn't open a block. +// --------------------------------------------------------------------------------------------- +const parseNginx = (text) => { + const root = []; + const stack = [root]; + let args = []; + let i = 0; + const n = text.length; + const isSpace = (c) => c === " " || c === "\t" || c === "\r" || c === "\n"; + + while (i < n) { + const c = text[i]; + if (isSpace(c)) { + i++; + } else if (c === "#") { + while (i < n && text[i] !== "\n") i++; + } else if (c === ";") { + if (!args.length) throw new Error(`unexpected ";" at ${i}`); + stack.at(-1).push({ name: args[0], args: args.slice(1) }); + args = []; + i++; + } else if (c === "{") { + if (!args.length) throw new Error(`unexpected "{" at ${i}`); + const block = { name: args[0], args: args.slice(1), block: [] }; + stack.at(-1).push(block); + stack.push(block.block); + args = []; + i++; + } else if (c === "}") { + if (args.length) throw new Error(`unexpected "}" at ${i}`); + if (stack.length === 1) throw new Error(`unbalanced "}" at ${i}`); + stack.pop(); + i++; + } else if (c === '"' || c === "'") { + let tok = c; + i++; + while (i < n && text[i] !== c) { + if (text[i] === "\\" && i + 1 < n) { + tok += text[i] + text[i + 1]; + i += 2; + } else { + tok += text[i++]; + } + } + if (i >= n) throw new Error("unterminated quoted string"); + tok += c; + i++; + if (i < n && !isSpace(text[i]) && text[i] !== ";" && text[i] !== "{") { + throw new Error(`unexpected "${text[i]}" after quoted string`); + } + args.push(tok); + } else { + let tok = ""; + while (i < n) { + const d = text[i]; + if (d === "\\" && i + 1 < n) { + tok += d + text[i + 1]; + i += 2; + continue; + } + if (d === "$" && text[i + 1] === "{") { + tok += "${"; + i += 2; + continue; + } + if (isSpace(d) || d === ";" || d === "{") break; + tok += d; + i++; + } + args.push(tok); + } + } + if (args.length) throw new Error("statement without ;"); + if (stack.length !== 1) throw new Error("unclosed block"); + return root; +}; + +// Statement names and argument counts, nested; values themselves don't matter +const shape = (stmts) => + stmts.map((s) => `${s.name}/${s.args.length}${s.block ? `{${shape(s.block).join(",")}}` : ""}`); + +const find = (stmts, name) => { + for (const s of stmts) { + if (s.name === name) return s; + if (s.block) { + const f = find(s.block, name); + if (f) return f; + } + } + return null; +}; + +const render = (file, ctx) => + utils.getRenderEngine().parseAndRender(fs.readFileSync(new URL(file, TEMPLATES), "utf8"), ctx); + +// Same structure as a plain value, or throws (a value that breaks parsing changes it too) +const sameShapeAs = async (file, makeCtx, plain, value) => { + const base = shape(parseNginx(await render(file, makeCtx(plain)))); + let got; + try { + got = shape(parseNginx(await render(file, makeCtx(value)))); + } catch { + return false; + } + return JSON.stringify(got) === JSON.stringify(base); +}; + +// --------------------------------------------------------------------------------------------- +// Corpora. "injections" add or change directives when rendered unchecked; "rejected" are other +// values the rules refuse (they would break or confuse the config without injecting). +// --------------------------------------------------------------------------------------------- +const cases = { + locationPath: { + valid: [ + "/", + "/api", + "/api/", + "= /exact", + "~ \\.php$", + "~* \\.(jpg|png|gif)$", + "^~ /static/", + '~ "^/v[0-9]{1,2}/"', + '"/with space"', + "/über", + "/a$b", + "/a\\;b", + ], + injections: [ + "/x { return 200; } location /y", + "/x {} include /etc/passwd; location /y", + "/a; } server { listen 8080", + "/a\nreturn 200 x;\nlocation /b", + '"/a" { return 200; } location "/b"', + "/a {", + ], + rejected: ["", " /a", "/a b", "/a#", "/a\\", '"/a', '"/a\\"', "'/a'", "~", "~ /a\tb", "/a}", "/a\u0000"], + }, + forwardHost: { + valid: ["127.0.0.1", "backend", "my_service.internal", "10.0.0.1/path/", "backend/$1", "[::1]", "host:8080"], + injections: [ + "a; return 200; #", + 'a"; return 200; set $x "', + "a:80; } location /x { return 200", + "a\nreturn 200;", + ], + rejected: ["", "a b", "a'", "a{", "a}", "a#b", "a\\b", "$http_host", "a$b", "/path", "a/b c"], + }, + forwardPath: { + valid: ["", "/", "/x", "/x/$1/"], + injections: ["/x; return 200", "/x\nreturn 200"], + rejected: ["/x y", "/x#", "/x{", "/x\\"], + }, + redirectDomain: { + valid: [ + "example.com", + "example.com:8443", + "example.com/path", + "sub.example.co.uk/a?b=c", + "example.com/$1", + "ab", + ], + injections: ["example.com; return 200 x; #", "example.com;\nreturn 200", "a.com { return 200; }"], + rejected: ["a b.com", "a.com#x", 'a.com"', "a.com\\", ".com", "a..b", "a", "a.com."], + }, + streamHost: { + valid: ["example.com", "1.2.3.4", "::1", "backend.internal"], + injections: ["a.com:80; } server { listen 9999", "a.com;\nlisten 1"], + rejected: ["a.com/x", "$host", "a b", "a.com#"], + }, + domainName: { + valid: ["example.com", "*.example.com", "xn--bcher-kva.example", "a-b.example"], + injections: ["a.com\nreturn 200 x", "a.com\n} server {"], + rejected: ["a\tb.com", "a;b", "a{b", "a b"], + }, + clientAddress: { + valid: ["192.168.0.1", "10.0.0.0/8", "all", "::1", "2001:db8::/32", "::ffff:192.168.1.1", "fe80::1"], + injections: ["::1%x; allow all", "::1%x\nallow all", "1.2.3.4; allow all"], + rejected: ["fe80::1%eth0", "s::1", "1.2.3.4/33", "::ffff:1d.1.1.1"], + }, + accessUsername: { + valid: ["admin", "john smith", "ü"], + injections: [], + rejected: ["a:b", "a\nroot:$apr1$x$y", "\u0000"], + }, +}; + +const checkers = { + locationPath: (v) => isValid("locationPath", v), + forwardHost: (v) => isValid("forwardHost", v), + forwardPath: (v) => isValid("forwardPath", v), + redirectDomain: (v) => isValid("redirectDomain", v), + streamHost: isValidStreamHost, + domainName: (v) => isValid("domainName", v), + clientAddress: isValidClientAddress, + accessUsername: (v) => isValid("accessUsername", v), +}; + +describe("nginx value rules", () => { + for (const [name, { valid, injections, rejected }] of Object.entries(cases)) { + it(`${name}: accepts valid values`, () => { + for (const v of valid) assert.ok(checkers[name](v), `should accept ${JSON.stringify(v)}`); + }); + it(`${name}: rejects injections and other unsafe values`, () => { + for (const v of [...injections, ...rejected]) { + assert.ok(!checkers[name](v), `should reject ${JSON.stringify(v)}`); + } + }); + } + + it("redirect domain rule matches the old rule for token-safe values", () => { + const old = /^(?:[^.*]+\.?)+[^.]$/u; + const corpus = [ + "ab", + "a.b", + "a.b.c", + "a..b", + ".a", + "a.", + "a*", + "a.*", + "*a", + "a*b", + "a.b:80/x", + "x", + "ab.", + "a.b.", + ]; + for (const v of corpus) { + assert.equal(isValid("redirectDomain", v), old.test(v), `differs for ${JSON.stringify(v)}`); + } + }); + + it("domain rules don't backtrack exponentially", () => { + const evil = `${"a".repeat(30000)}.`; + const start = process.hrtime.bigint(); + isValid("redirectDomain", evil); + isValidStreamHost(evil); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + assert.ok(ms < 1000, `took ${ms}ms`); + }); +}); + +describe("API request validation", async () => { + await getCompiledSchema(); + + const location = (o) => ({ path: "/api", forward_scheme: "http", forward_host: "backend", forward_port: 80, ...o }); + const proxyHost = (o) => ({ + domain_names: ["example.com"], + forward_scheme: "http", + forward_host: "backend", + forward_port: 80, + ...o, + }); + const redirection = (o) => ({ + domain_names: ["example.com"], + forward_scheme: "http", + forward_http_code: 301, + forward_domain_name: "example.org", + ...o, + }); + const stream = (o) => ({ + incoming_port: 9000, + forwarding_host: "backend", + forwarding_port: 80, + tcp_forwarding: true, + udp_forwarding: false, + ...o, + }); + + // [field, endpoint, method, payload builder] + const endpoints = { + locationPath: [ + ["/nginx/proxy-hosts", "post", (v) => proxyHost({ locations: [location({ path: v })] })], + ["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ path: v })] })], + ], + forwardHost: [ + ["/nginx/proxy-hosts", "post", (v) => proxyHost({ forward_host: v })], + ["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ forward_host: v })], + ["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_host: v })] })], + ], + forwardPath: [["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_path: v })] })]], + redirectDomain: [ + ["/nginx/redirection-hosts", "post", (v) => redirection({ forward_domain_name: v })], + ["/nginx/redirection-hosts/{hostID}", "put", (v) => ({ forward_domain_name: v })], + ], + streamHost: [ + ["/nginx/streams", "post", (v) => stream({ forwarding_host: v })], + ["/nginx/streams/{streamID}", "put", (v) => ({ forwarding_host: v })], + ], + domainName: [ + ["/nginx/proxy-hosts", "post", (v) => proxyHost({ domain_names: [v] })], + ["/nginx/redirection-hosts", "post", (v) => redirection({ domain_names: [v] })], + ["/nginx/dead-hosts", "post", (v) => ({ domain_names: [v] })], + ["/nginx/certificates", "post", (v) => ({ provider: "letsencrypt", domain_names: [v] })], + ], + clientAddress: [ + ["/nginx/access-lists", "post", (v) => ({ name: "x", clients: [{ directive: "allow", address: v }] })], + ["/nginx/access-lists/{listID}", "put", (v) => ({ clients: [{ directive: "allow", address: v }] })], + ], + accessUsername: [ + ["/nginx/access-lists", "post", (v) => ({ name: "x", items: [{ username: v, password: "pw" }] })], + ["/nginx/access-lists/{listID}", "put", (v) => ({ items: [{ username: v, password: "pw" }] })], + ], + }; + + const validate = (path, method, payload) => { + const schema = getValidationSchema(path, method); + assert.ok(schema, `no request schema for ${method} ${path}`); + return apiValidator(schema, payload); + }; + + for (const [name, list] of Object.entries(endpoints)) { + for (const [path, method, build] of list) { + it(`${method.toUpperCase()} ${path} ${name}: rejects injections`, async () => { + for (const v of [...cases[name].injections, ...cases[name].rejected]) { + if (v === "") continue; // minLength cases, covered by the rules above + await assert.rejects(validate(path, method, build(v)), /must/, `accepted ${JSON.stringify(v)}`); + } + }); + it(`${method.toUpperCase()} ${path} ${name}: accepts valid values`, async () => { + for (const v of cases[name].valid) { + if (name === "forwardHost" && v === "") continue; + await validate(path, method, build(v)); + } + }); + } + } + + it("schema patterns are the ones the render-time check uses", async () => { + const s = await getCompiledSchema(); + const body = (path, method) => s.paths[path][method].requestBody.content["application/json"].schema; + const ph = body("/nginx/proxy-hosts", "post").properties; + const loc = ph.locations.items.properties; + const al = body("/nginx/access-lists", "post").properties; + assert.equal(ph.domain_names.items.pattern, patterns.domainName); + assert.equal(body("/nginx/certificates", "post").properties.domain_names.items.pattern, patterns.domainName); + assert.equal(ph.forward_host.pattern, patterns.forwardHost); + assert.equal(loc.path.pattern, patterns.locationPath); + assert.equal(loc.forward_host.pattern, patterns.forwardHost); + assert.equal(loc.forward_path.pattern, patterns.forwardPath); + assert.equal( + body("/nginx/redirection-hosts", "post").properties.forward_domain_name.pattern, + patterns.redirectDomain, + ); + assert.equal(body("/nginx/streams", "post").properties.forwarding_host.anyOf[0].pattern, patterns.streamDomain); + assert.equal(al.clients.items.properties.address.oneOf[1].pattern, patterns.clientIpv6); + assert.equal(al.items.items.properties.username.pattern, patterns.accessUsername); + }); +}); + +describe("rendered templates", () => { + const locationCtx = (field) => (v) => ({ + path: "/api", + forward_scheme: "http", + forward_host: "backend", + forward_port: 8080, + forward_path: "", + advanced_config: "", + access_list_id: 0, + [field]: v, + }); + const proxyCtx = (field) => (v) => ({ + enabled: true, + id: 1, + domain_names: ["example.com"], + forward_scheme: "http", + forward_host: "backend", + forward_port: 80, + certificate_id: 0, + locations: "", + advanced_config: "", + use_default_location: true, + ipv6: true, + [field]: v, + }); + const redirectCtx = (v) => ({ + enabled: true, + id: 1, + domain_names: ["example.com"], + forward_scheme: "http", + forward_http_code: 301, + forward_domain_name: v, + preserve_path: true, + advanced_config: "", + use_default_location: true, + ipv6: true, + }); + const streamCtx = (v) => ({ + enabled: true, + id: 1, + incoming_port: 9000, + forwarding_host: v, + forwarding_port: 80, + tcp_forwarding: true, + udp_forwarding: true, + ipv6: true, + }); + const accessCtx = (v) => ({ + access_list_id: 1, + access_list: { items: [], clients: [{ directive: "allow", address: v }], satisfy_any: false }, + }); + + // A location modifier is a separate argument, so compare with a plain path using the same one + const plainPath = (v) => { + const m = v.match(/^(=|~\*?|\^~) +/); + return m ? `${m[1]} /api` : "/api"; + }; + + // [template, context builder, plain value (or builder), injections that apply to it] + const targets = { + locationPath: [["_location.conf", locationCtx("path"), plainPath]], + forwardHost: [ + ["_location.conf", locationCtx("forward_host"), "backend"], + // Rendered inside double quotes here, so only a quote can break out + ["proxy_host.conf", proxyCtx("forward_host"), "backend", ['a"; return 200; set $x "', 'a" ; } server { "']], + ], + forwardPath: [["_location.conf", locationCtx("forward_path"), "/x"]], + redirectDomain: [["redirection_host.conf", redirectCtx, "example.org"]], + streamHost: [["stream.conf", streamCtx, "backend"]], + domainName: [["proxy_host.conf", (v) => proxyCtx("domain_names")([v]), "example.com"]], + clientAddress: [["_access.conf", accessCtx, "10.0.0.1"]], + }; + + for (const [name, list] of Object.entries(targets)) { + for (const [file, ctx, plain, injections = cases[name].injections] of list) { + const plainFor = (v) => (typeof plain === "function" ? plain(v) : plain); + it(`${file} ${name}: accepted values keep the statement structure`, async () => { + for (const v of cases[name].valid) { + if (v === "" && name !== "forwardPath") continue; + // generateConfig brackets IPv6 stream hosts before rendering + const value = name === "streamHost" && v.includes(":") ? `[${v}]` : v; + assert.ok( + await sameShapeAs(file, ctx, plainFor(v), value), + `${JSON.stringify(v)} changed the config`, + ); + } + }); + it(`${file} ${name}: the injection corpus would change it unchecked`, async () => { + assert.ok(injections.length > 0); + for (const v of injections) { + assert.ok(!checkers[name](v), `rules accept ${JSON.stringify(v)}`); + assert.ok( + !(await sameShapeAs(file, ctx, plainFor(v), v)), + `${JSON.stringify(v)} is not an injection`, + ); + } + }); + } + } + + it("location path renders as the location's own arguments", async () => { + for (const v of cases.locationPath.valid) { + const loc = find(parseNginx(await render("_location.conf", locationCtx("path")(v))), "location"); + assert.equal(loc.args.join(" "), v.replace(/ +/, " ")); + } + }); +}); + +describe("render-time check (rows saved before validation)", () => { + const host = (o) => ({ + id: 7, + domain_names: ["example.com"], + forward_host: "backend", + locations: [], + ...o, + }); + + it("passes valid hosts", () => { + assertHostRenderable("proxy_host", host({ locations: [{ path: "~* \\.php$", forward_host: "10.0.0.1/x/" }] })); + assertHostRenderable("redirection_host", host({ forward_domain_name: "example.org/path" })); + assertHostRenderable("dead_host", host({})); + assertHostRenderable("stream", { id: 1, forwarding_host: "::1" }); + assertHostRenderable( + "proxy_host", + host({ access_list: { clients: [{ directive: "allow", address: "10.0.0.0/8" }] } }), + ); + }); + + it("still renders stored values the API no longer accepts but that can't add directives", () => { + // e.g. an IPv6 literal server name, as the host-header smoke test renders + assertHostRenderable("proxy_host", host({ domain_names: ["host.example.test", "[::1]"] })); + assertHostRenderable("proxy_host", host({ forward_host: "$upstream" })); + assertHostRenderable("redirection_host", host({ forward_domain_name: "a" })); + assertHostRenderable("stream", { id: 1, forwarding_host: "a.com/x" }); + }); + + const refuse = (type, row, what) => { + assert.throws( + () => assertHostRenderable(type, row), + (err) => err.unsafeNginxValue === true && err.message.includes(what), + ); + }; + + it("refuses every injected field", () => { + for (const v of cases.locationPath.injections) { + refuse("proxy_host", host({ locations: [{ path: v, forward_host: "b" }] }), "location path"); + } + for (const v of cases.forwardHost.injections) { + refuse("proxy_host", host({ forward_host: v }), "forward host"); + refuse("proxy_host", host({ locations: [{ path: "/a", forward_host: v }] }), "location forward host"); + } + for (const v of cases.forwardPath.injections) { + refuse( + "proxy_host", + host({ locations: [{ path: "/a", forward_host: "b", forward_path: v }] }), + "forward path", + ); + } + for (const v of cases.redirectDomain.injections) { + refuse("redirection_host", host({ forward_domain_name: v }), "forward domain"); + } + for (const v of cases.streamHost.injections) { + refuse("stream", { id: 1, forwarding_host: v }, "forwarding host"); + } + for (const v of cases.domainName.injections) { + refuse("dead_host", host({ domain_names: [v] }), "domain name"); + } + for (const v of cases.clientAddress.injections) { + refuse("proxy_host", host({ access_list: { clients: [{ directive: "allow", address: v }] } }), "address"); + } + }); +}); diff --git a/backend/yarn.lock b/backend/yarn.lock index 39efe544a..30c1fcc72 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -2,20 +2,12 @@ # yarn lockfile v1 -"@apidevtools/json-schema-ref-parser@15.3.6": - version "15.3.6" - resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.3.6.tgz#9e6360264d90b209eb09f9e4dc9a32316d86f4ea" - integrity sha512-oPFDSviRrreUmCZn09LCD4S9QQa6j7zfEwBM1pkGa/kXY0O4sXsEXn4emLP/Tt6tik2+BtgwKk2eldAeOWZ2Rw== +"@apidevtools/json-schema-ref-parser@^16.0.3", "@apidevtools/json-schema-ref-parser@^16.0.6": + version "16.0.6" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.6.tgz#30b21ddbaa6ddd1161d1534829002dab0d720980" + integrity sha512-cJXhmvqCn4Ud7X7d4L/BtAiJRIrLNiuIZ48ZTuDfN0mYejVXxdzMGR07teW6eKzLwcN0FwMi7gykE2Kr09LS1g== dependencies: - js-yaml "^4.2.0" - -"@apidevtools/json-schema-ref-parser@^16.0.2": - version "16.0.2" - resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.2.tgz#21f55458a55d1b02bc62f19c731913d8e74976b3" - integrity sha512-oCwWUyRdMtt/hnp6ONPudFZzF7tSqiHy46lLx5VsnsLTacs8TSU558VPAiOueFJ8WddCH30PINWDA8ByxhGHmQ== - dependencies: - js-yaml "^5.4.1" - undici "^8.10.2" + js-yaml "^5.4.2" "@apidevtools/openapi-schemas@^2.1.0": version "2.1.0" @@ -27,72 +19,84 @@ resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== -"@apidevtools/swagger-parser@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-13.0.0.tgz#a8363c086cc2ae0005b270cfc241f353a741e49c" - integrity sha512-TcvnmBzJD7DBREkc/2qaxMqbKpttOaHhOFsYRYKdYAv6HGY+iNlWY2IUA209C2/NmvL9z8RBZhkrESqvGkjqmw== +"@apidevtools/swagger-parser@^13.1.0": + version "13.1.0" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-13.1.0.tgz#efc8ffcc573838e715d0bce83d917b79636ca172" + integrity sha512-dkCuqCWh5U7u0aurmmc/qvel2KeKDxHTwVtUdYonndIpD7mXV6AVaWNdvaja2HAy628z7FT/Qdz8sCgzwhgwKg== dependencies: - "@apidevtools/json-schema-ref-parser" "15.3.6" + "@apidevtools/json-schema-ref-parser" "^16.0.3" "@apidevtools/openapi-schemas" "^2.1.0" "@apidevtools/swagger-methods" "^3.0.2" "@types/json-schema" "^7.0.15" - ajv "^8.17.1" + ajv "^8.20.0" ajv-draft-04 "^1.0.0" call-me-maybe "^1.0.2" -"@biomejs/biome@^2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.5.10.tgz#c2a42fc6f9c7d9e12c1f77f63dfb6b5489c497b7" - integrity sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ== +"@biomejs/biome@^2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.6.6.tgz#b40e03ff3dbe070963dd6332171ffffeb3cf7619" + integrity sha512-NCgVJRyrU1+hsW1ILNaRqNAb9fRL4n49bwyef+wjVJQ488/bXbp0BfnNqTTExkiNQT3ugkgYKR1vK8NxsjlNSQ== optionalDependencies: - "@biomejs/cli-darwin-arm64" "2.5.10" - "@biomejs/cli-darwin-x64" "2.5.10" - "@biomejs/cli-linux-arm64" "2.5.10" - "@biomejs/cli-linux-arm64-musl" "2.5.10" - "@biomejs/cli-linux-x64" "2.5.10" - "@biomejs/cli-linux-x64-musl" "2.5.10" - "@biomejs/cli-win32-arm64" "2.5.10" - "@biomejs/cli-win32-x64" "2.5.10" + "@biomejs/cli-android-arm64" "2.6.6" + "@biomejs/cli-android-x64" "2.6.6" + "@biomejs/cli-darwin-arm64" "2.6.6" + "@biomejs/cli-darwin-x64" "2.6.6" + "@biomejs/cli-linux-arm64" "2.6.6" + "@biomejs/cli-linux-arm64-musl" "2.6.6" + "@biomejs/cli-linux-x64" "2.6.6" + "@biomejs/cli-linux-x64-musl" "2.6.6" + "@biomejs/cli-win32-arm64" "2.6.6" + "@biomejs/cli-win32-x64" "2.6.6" -"@biomejs/cli-darwin-arm64@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz#f1453a7f2e63f228a34645df1c33962f22a6c2a8" - integrity sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q== +"@biomejs/cli-android-arm64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-android-arm64/-/cli-android-arm64-2.6.6.tgz#dd4a994b35837a521459fea4f4f27a194c47258d" + integrity sha512-bicnqNNxEX7pjkDQaptYvPRIWDfgXJLl/Ok7iZjC+F1AhfReor3FSS7SgPYnOvUy/jf++5U/KYN4T8OKH5rrNA== -"@biomejs/cli-darwin-x64@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz#e21041e7bfab01cd453f609b940bf613713d0e24" - integrity sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw== +"@biomejs/cli-android-x64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-android-x64/-/cli-android-x64-2.6.6.tgz#1804e55a64e9e0b11f56492863c98fd80f1a4028" + integrity sha512-N443kDSElF4kDMKjarsL+ymj/ewLA5R9VAdawA7fJ7pwgN3JVDZp0K+xhKBglH8EwsKi2+K8xC6WxXSkgziFfw== -"@biomejs/cli-linux-arm64-musl@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz#57fc8aee25c08ca6c14526ccc44e282e7ba96eb6" - integrity sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw== +"@biomejs/cli-darwin-arm64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.6.6.tgz#dd6be6a1d8457a465c555311c909e6aa81f1c9ea" + integrity sha512-/vSAqWx3by3p8w5ew+HVJDgguuaTK3U6Fb5gk/oDUH21iWOopm3gOlhT6tZfy5mTaM5R4CVc3uFWkxBtEDrKyw== -"@biomejs/cli-linux-arm64@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz#aef789a71e5aa86f5a0e1aff6cb4c1b757ec5be1" - integrity sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg== +"@biomejs/cli-darwin-x64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.6.6.tgz#fb2609d97f4ead53dbff30507a42be1fe56a1e9c" + integrity sha512-lLdOAJKbrAoDs+3GvTyVmMW345D9IhrIE3GgoL2eeHwO1boN4VhrSlALtFYsP0p+Jy4udUp4bTguij0qjKrH8w== -"@biomejs/cli-linux-x64-musl@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz#1aebe9f928dc72dc3c112e60f1211a4d646990c4" - integrity sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ== +"@biomejs/cli-linux-arm64-musl@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.6.6.tgz#28412c5039466b87c9d3a59176994a45422d60a4" + integrity sha512-rw2Vsoi/WZGDyRjMXx9KZH9zsHe79LIzkdWHkLqsIkeKZm6qiGxtiIApxn7cmJSoEPaSvHaH5pRj1FhTcyL/XA== -"@biomejs/cli-linux-x64@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz#a82b414abf5d9668fb035ee50a710bf78a7c5fbf" - integrity sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg== +"@biomejs/cli-linux-arm64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.6.6.tgz#04ced06069af3c7a4d20da44c53c21e70ce42176" + integrity sha512-UzJdUCg9dbEGpZOuZRyehEPrnwKjx9z4Um7sWLG5x6txu7UjeWdJZuBXVGlyAdD39w2Hx0nZeK1jKC4NFUTCNQ== -"@biomejs/cli-win32-arm64@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz#db671cf67d7040a374e8cdda5aebe73c3f4ffba0" - integrity sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A== +"@biomejs/cli-linux-x64-musl@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.6.6.tgz#78109fb485dcbce9eaf5977e2a70658696dc6405" + integrity sha512-DiKejs+GnFbOVQsBnukUpiB+T13tqUDFGwwjsTq51vatAsaflzFzVD0BOgjVUGRrqKgE0K0Xf+wNtkcmdN2chw== -"@biomejs/cli-win32-x64@2.5.10": - version "2.5.10" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz#3bdeaf5856f21aec6b6c50a7bca5464b9cda5623" - integrity sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA== +"@biomejs/cli-linux-x64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.6.6.tgz#b5315ccaa511a2bfb103db50013a266887d793bb" + integrity sha512-l8/aPXc1cz8VpUyd4hb5Pm5zx+X3voQBks4aLYbp5pC8KSchuHyvqDfY5xax39qCHomI/2xn6NKEot+zebG38g== + +"@biomejs/cli-win32-arm64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.6.6.tgz#41c0a80a9b98e9b879fb2413a5faf0775a526f07" + integrity sha512-DjVXwQAQeHrO1JEZAEus54k40Tnh4glyU3VFLcDBKwoz0xnxCEnqw6TCMXoujcQMQynWd3AY8bLrbR1o0XNqow== + +"@biomejs/cli-win32-x64@2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.6.6.tgz#f3e5230d59a48ac1da6ab951ad5be364591fb64c" + integrity sha512-B7KMG26A4I9lAuxwBGMmiVcjMGAeJRoz+pzFt9brRvOAsIrgOu8ptJld5rUWui6tM1uiJwxeJhhDPuAiZtxXdw== "@isaacs/fs-minipass@^4.0.0": version "4.0.1" @@ -101,60 +105,60 @@ dependencies: minipass "^7.0.4" -"@noble/hashes@^2.2.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.3.0.tgz#505fd39c3134a37e67c8c4e6c6049a496154879c" - integrity sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ== +"@noble/hashes@^2.2.0", "@noble/hashes@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.4.0.tgz#e6a3e98edbed3c8659b28bb8f9b18ca02ec8ea4b" + integrity sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA== -"@otplib/core@13.5.0": - version "13.5.0" - resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.5.0.tgz#629a7fef5b6394f34ff55213b936223b368796a4" - integrity sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ== +"@otplib/core@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.6.0.tgz#f939a821604467b87be29b465cb54e2f270c3789" + integrity sha512-TL6/FKKzR55Vr7zo3YGFtPMCSV3SpYPtuvE3T1XcwjFczD9pHU4dYYQBl0AcNvGcqAt4/fPqhZEFfB2OBcnlPA== -"@otplib/hotp@13.5.0": - version "13.5.0" - resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.5.0.tgz#2832cc6e52f3d037428f094177b4779babe19aa5" - integrity sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw== +"@otplib/hotp@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.6.0.tgz#b9c0e2ed27f63dff9bb704c881bb006f54a2d4b4" + integrity sha512-oPpaQBkAYjBxAs+FWdGNumsEwWJhIZVcsv0vEi+w4OzzLQ6UDZ2qXZNSeF9NljTj6/8d4e1Z4o6RgJa3aIxAOA== dependencies: - "@otplib/core" "13.5.0" - "@otplib/uri" "13.5.0" + "@otplib/core" "13.6.0" + "@otplib/uri" "13.6.0" -"@otplib/plugin-base32-scure@13.5.0": - version "13.5.0" - resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.5.0.tgz#ba2f329aeea3ed4df3c4e21ce2e9c2e8c9aaed4f" - integrity sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA== +"@otplib/plugin-base32-scure@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.6.0.tgz#73509fbd9946642dd78f2990190f5cdb86aa0f81" + integrity sha512-s6uMbj4MBebvAPnBns1y2RhQ765/U04qHlZmFLV02dlD1S06pU46g/7tHX0vkCx5Pnty723loXto/PM5gFEEdg== dependencies: - "@otplib/core" "13.5.0" - "@scure/base" "^2.2.0" + "@otplib/core" "13.6.0" + "@scure/base" "^2.4.0" -"@otplib/plugin-crypto-noble@13.5.0": - version "13.5.0" - resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.5.0.tgz#820227beb159090a7448ce7245a4e615b5579f0e" - integrity sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA== +"@otplib/plugin-crypto-noble@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.6.0.tgz#c2702906e6cc128cf8b32fafd35163683a02394e" + integrity sha512-m/8mOhtIhsr4zLtb9MlLI6+w7ZNiKSFnT8Dv7eHR9TCyf2Sxva5e+MbV0wONmF7ZLuvo9F3DtzfPjc748PhJcw== dependencies: - "@noble/hashes" "^2.2.0" - "@otplib/core" "13.5.0" + "@noble/hashes" "^2.4.0" + "@otplib/core" "13.6.0" -"@otplib/totp@13.5.0": - version "13.5.0" - resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.5.0.tgz#48a60807457726ea34f1d1185f994120652e4468" - integrity sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg== +"@otplib/totp@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.6.0.tgz#fa309ced2594e83b060d9b746d0218d2957153a3" + integrity sha512-bMlq0MaUaQE3lOSH352Mrzcwpau5pMf7pMLROS+llk2evpC/sE7SHl9ajBl+PiPnGGb6LGYEjDvKqX8caXkGdw== dependencies: - "@otplib/core" "13.5.0" - "@otplib/hotp" "13.5.0" - "@otplib/uri" "13.5.0" + "@otplib/core" "13.6.0" + "@otplib/hotp" "13.6.0" + "@otplib/uri" "13.6.0" -"@otplib/uri@13.5.0": - version "13.5.0" - resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.5.0.tgz#d2da470be24f872bb1c24717bcdd2e5c122e3ce3" - integrity sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw== +"@otplib/uri@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.6.0.tgz#c26e70293ccd4bc8f1d6e46c051e2b70bf1a56dc" + integrity sha512-CiLFMONTpCi3ara6yQQX3nlpYhafCALKJweA7+7Z+aDkRrW3QmD5a/9thKmQ0cGp2W70AMG/6BSTy82h74LHRw== dependencies: - "@otplib/core" "13.5.0" + "@otplib/core" "13.6.0" -"@scure/base@^2.2.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.3.0.tgz#fc7acb5b7084c53e4e2537d83fa72cddac7143fd" - integrity sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA== +"@scure/base@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.4.0.tgz#61e4ed7d4447afc247f2bff9aeb8fca53267db86" + integrity sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg== "@types/json-schema@^7.0.15": version "7.0.15" @@ -191,10 +195,10 @@ ajv-draft-04@^1.0.0: resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8" integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw== -ajv-formats@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" - integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== dependencies: ajv "^8.0.0" @@ -250,10 +254,10 @@ archiver@^8.0.0: tar-stream "^3.0.0" zip-stream "^7.0.2" -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +argparse@^3.0.3: + version "3.0.4" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-3.0.4.tgz#ba802dd75fdc3d5bdcb4339b713d62c6c5d4565d" + integrity sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA== ast-types@^0.13.4: version "0.13.4" @@ -272,25 +276,30 @@ aws-ssl-profiles@^1.1.2: resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== -b4a@^1.6.4, b4a@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.1.tgz#7f16334ca80127aeb26064a28841acbf174840a4" - integrity sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw== +b4a@^1.6.4, b4a@^1.8.1, b4a@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.9.0.tgz#6336313b6e76b299c397afa68676ec6ebc788845" + integrity sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ== balanced-match@^4.0.2: version "4.0.4" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -bare-events@^2.5.4, bare-events@^2.7.0: - version "2.9.1" - resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.1.tgz#5c86616966343bcb03a1b3155feab253eadbf349" - integrity sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg== +bare-buffer@^3.0.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/bare-buffer/-/bare-buffer-3.7.1.tgz#dcd2fb7907cfe7df0cf18cb752a04ae2a41140bc" + integrity sha512-cIjZnSO+y89ykVX9x96OVnA6tGORBn7vGJ/e3PCbBHMEyfMNG/ixzOS0lLa6saMlWcs69bSKjxFyDwjK152OMg== -bare-fs@^4.5.5: - version "4.8.0" - resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.0.tgz#8c0f916833ae11e6d9d347f9e0a7f46f7375bb8f" - integrity sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q== +bare-events@^2.5.4, bare-events@^2.7.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.2.tgz#01d377b64c3c7167b28b52da7add3221aecf2d7f" + integrity sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA== + +bare-fs@^4.8.2: + version "4.8.4" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.4.tgz#f342288e7e5d9d8e79917078e1f45950507828c5" + integrity sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ== dependencies: bare-events "^2.5.4" bare-path "^3.0.0" @@ -299,24 +308,25 @@ bare-fs@^4.5.5: fast-fifo "^1.3.2" bare-path@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.1.tgz#d4a207c088609b4663a7556a46a97342398bf7e2" - integrity sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ== + version "3.1.2" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.2.tgz#67261bc74a9ba0105ba139b8638f218c09e8d82e" + integrity sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw== bare-stream@^2.6.4: - version "2.13.3" - resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.3.tgz#f6186c7cbb4bbf53a4560f35e48b16373ba51ce6" - integrity sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ== + version "2.13.4" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.4.tgz#61d448a268d1efd992103aae6bd729332f278ac0" + integrity sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA== dependencies: b4a "^1.8.1" streamx "^2.25.0" teex "^1.0.1" bare-url@^2.2.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.2.tgz#e8dafaefbdb56891a0fbc2b0e1c051296d241cd6" - integrity sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg== + version "2.5.6" + resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.6.tgz#7f7a96c459bd432bec43f797c0afca1325cb9b5f" + integrity sha512-iqkXHkW65De+BT14nHLiBFbNuJ/DdMrZaVNJ1IXPctpK6b1olUJ5Qmy8QBvwROajTiGztso2n82kFEvCsNG7Rg== dependencies: + bare-buffer "^3.0.0" bare-path "^3.0.0" base64-js@^1.3.1: @@ -375,25 +385,25 @@ blueimp-md5@^2.16.0: resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0" integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w== -body-parser@^2.2.1, body-parser@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" - integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== +body-parser@^2.3.0, body-parser@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.4.0.tgz#337a76249270f83e450ad385a9606ca3b4a75c06" + integrity sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ== dependencies: bytes "^3.1.2" - content-type "^2.0.0" + content-type "^2.1.0" debug "^4.4.3" http-errors "^2.0.1" - iconv-lite "^0.7.2" + iconv-lite "^0.7.3" on-finished "^2.4.1" - qs "^6.15.2" + qs "^6.16.1" raw-body "^3.0.2" type-is "^2.1.0" brace-expansion@^5.0.8: - version "5.0.9" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" - integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== + version "5.0.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== dependencies: balanced-match "^4.0.2" @@ -468,10 +478,10 @@ camelcase@^5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -chalk@5.6.2: - version "5.6.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" - integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== +chalk@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-6.0.1.tgz#077d4667ad6e608c9687782caed3d534d7f9abc3" + integrity sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA== chalk@^2.3.2: version "2.4.2" @@ -582,15 +592,10 @@ compression@^1.8.2: safe-buffer "5.2.1" vary "~1.1.2" -content-disposition@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" - integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== - -content-type@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== +content-disposition@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-2.0.1.tgz#88a75c7a52ef9c407c96035fe83a66939c2a4dc9" + integrity sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog== content-type@^2.0.0, content-type@^2.1.0: version "2.1.0" @@ -630,11 +635,6 @@ data-uri-to-buffer@8.0.0: resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz#c642ffbc96d8fae76cccecbad43cbddd7818c785" integrity sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ== -db-errors@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/db-errors/-/db-errors-0.2.3.tgz#a6a38952e00b20e790f2695a6446b3c65497ffa2" - integrity sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng== - debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -729,9 +729,9 @@ emoji-regex@^8.0.0: integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== encodeurl@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + version "2.0.1" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.1.tgz#f268548822bd87b5b4fdce83c56388c6d33c9de6" + integrity sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A== end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.5" @@ -847,22 +847,22 @@ exponential-backoff@^3.1.1: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" integrity sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA== -express-fileupload@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/express-fileupload/-/express-fileupload-1.5.2.tgz#4da70ba6f2ffd4c736eab0776445865a9dbd9bfa" - integrity sha512-wxUJn2vTHvj/kZCVmc5/bJO15C7aSMyHeuXYY3geKpeKibaAoQGcEv5+sM6nHS2T7VF+QHS4hTWPiY2mKofEdg== +express-fileupload@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/express-fileupload/-/express-fileupload-1.6.0.tgz#fbc7035f090a554ae54005b1d8a7bb46c8440a62" + integrity sha512-jIVP7PB+uNLrsglQUKzlK8PjxGkzPy3h0kH0XKpoxQnoT5sPNnHvfWnMMkLoP36LJS5Y8NkkdvkAYQnGCB6tdQ== dependencies: busboy "^1.6.0" -express@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" - integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== +express@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/express/-/express-5.3.0.tgz#ab72dace89c50c8417bd3f865dad0b4fd1ca1c73" + integrity sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g== dependencies: accepts "^2.0.0" - body-parser "^2.2.1" - content-disposition "^1.0.0" - content-type "^1.0.5" + body-parser "^2.3.0" + content-disposition "^2.0.1" + content-type "^2.0.0" cookie "^0.7.1" cookie-signature "^1.2.1" debug "^4.4.0" @@ -878,14 +878,14 @@ express@^5.2.1: on-finished "^2.4.1" once "^1.4.0" parseurl "^1.3.3" - proxy-addr "^2.0.7" - qs "^6.14.0" + proxy-addr "^2.0.8" + qs "^6.16.0" range-parser "^1.2.1" router "^2.2.0" send "^1.1.0" serve-static "^2.2.0" statuses "^2.0.1" - type-is "^2.0.1" + type-is "^2.1.0" vary "^1.1.2" fast-deep-equal@^3.1.3: @@ -893,15 +893,15 @@ fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-fifo@^1.2.0, fast-fifo@^1.3.2: +fast-fifo@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== fast-uri@^3.0.1: - version "3.1.7" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a" - integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg== + version "3.1.9" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.9.tgz#f1deba822b1d6ac79da2f860e872a63470cff203" + integrity sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ== fdir@^6.5.0: version "6.5.0" @@ -1076,7 +1076,7 @@ has-symbols@^1.1.0: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== -hasown@^2.0.2, hasown@^2.0.3: +hasown@^2.0.2, hasown@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== @@ -1112,7 +1112,7 @@ https-proxy-agent@9.1.0: debug "^4.3.4" proxy-agent-negotiate "1.1.0" -iconv-lite@^0.7.2, iconv-lite@^0.7.3, iconv-lite@~0.7.0: +iconv-lite@^0.7.3, iconv-lite@~0.7.0: version "0.7.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== @@ -1150,9 +1150,9 @@ interpret@^2.2.0: integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== ip-address@^10.1.1: - version "10.5.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.5.0.tgz#fcd6d7dfe9e68416b7cb71afe9bc960b3e38c13b" - integrity sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g== + version "10.7.6" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.7.6.tgz#e3c06961cedf352494c06c916c696cfdb11068dd" + integrity sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg== ipaddr.js@1.9.1: version "1.9.1" @@ -1171,12 +1171,12 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-core-module@^2.16.1: - version "2.16.2" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" - integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== +is-core-module@^2.18.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.18.0.tgz#8a988af8d3729fb6bbcaf025bf820cfe46b95f4c" + integrity sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw== dependencies: - hasown "^2.0.3" + hasown "^2.0.4" is-extglob@^2.1.1: version "2.1.1" @@ -1225,19 +1225,12 @@ isexe@^4.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-4.0.0.tgz#48f6576af8e87a18feb796b7ed5e2e5903b43dca" integrity sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw== -js-yaml@^4.2.0: - version "4.3.2" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb" - integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA== +js-yaml@^5.4.2: + version "5.5.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.5.0.tgz#ec843f09ffb2a54b6758a41eee109a1815d16442" + integrity sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg== dependencies: - argparse "^2.0.1" - -js-yaml@^5.4.1: - version "5.4.2" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.4.2.tgz#eebc7a57e0bbe1d6b6d9416a72e7463082c7da85" - integrity sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg== - dependencies: - argparse "^2.0.1" + argparse "^3.0.3" json-parse-better-errors@^1.0.1: version "1.0.2" @@ -1309,10 +1302,10 @@ lazystream@^1.0.0: dependencies: readable-stream "^2.0.5" -liquidjs@10.29.0: - version "10.29.0" - resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.29.0.tgz#ee061890ccf0da26be377aad4f02bd32da0140d8" - integrity sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw== +liquidjs@10.30.1: + version "10.30.1" + resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.30.1.tgz#aa8c24535d638f88679a22cd8042c38b3ca61dc6" + integrity sha512-P1Iz14WUHZdfbOYwHOIhtl/iAWIOYk1ks5l9rPZlDM6vZo+6BLeYIuWHAPkIz+U8Mm8HKZKITIrjX7DQJO2fqA== dependencies: commander "^10.0.0" @@ -1392,9 +1385,9 @@ lru-cache@^7.14.1: integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== lru.min@^1.1.0, lru.min@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b" - integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== + version "1.1.5" + resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.5.tgz#bd32483f4e342261d0eb011b06d2219e6b5ef818" + integrity sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA== math-intrinsics@^1.1.0: version "1.1.0" @@ -1477,10 +1470,10 @@ ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -mysql2@^3.24.4: - version "3.24.4" - resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.24.4.tgz#7752de0cf10b1d2984103bdbc60f696c60756f31" - integrity sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA== +mysql2@^3.24.6: + version "3.24.6" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.24.6.tgz#8e9471f0ceca917599ac021356bb24f08f168b3c" + integrity sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ== dependencies: aws-ssl-profiles "^1.1.2" generate-function "^2.3.1" @@ -1520,9 +1513,9 @@ netmask@^2.0.2: integrity sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA== node-abi@^3.3.0: - version "3.94.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.94.0.tgz#007181ed0d1b56ae9670ea6c084d2bf83538405f" - integrity sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g== + version "3.101.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.101.0.tgz#3c99775cb0a4ddf4f3f0dad2a9ad940d67ef0052" + integrity sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ== dependencies: semver "^7.3.5" @@ -1592,19 +1585,18 @@ object-inspect@^1.13.3, object-inspect@^1.13.4: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== -objection@3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/objection/-/objection-3.1.5.tgz#53c32f6b6cba2958bc28cf723de96c2676da8286" - integrity sha512-Hx/ipAwXSuRBbOMWFKtRsAN0yITafqXtWB4OT4Z9wED7ty1h7bOnBdhLtcNus23GwLJqcMsRWdodL2p5GwlnfQ== +objection@4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/objection/-/objection-4.4.3.tgz#aa965ccb29cfd97b3717539ac73f9095c94a5cbf" + integrity sha512-Ie1jBkoQvzCT7SBuh1nNl/c2gC6lACOy6vY7dygWyZr70bP1Um660XbxCLhXAQ9Uqwok4LWLjzsnKgNd7JpV6w== dependencies: ajv "^8.17.1" - ajv-formats "^2.1.1" - db-errors "^0.2.3" + ajv-formats "^3.0.1" on-finished@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + version "2.4.2" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.2.tgz#6932b04b95bf6a4828201db10af75fe5c9c38b73" + integrity sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ== dependencies: ee-first "1.1.1" @@ -1620,17 +1612,17 @@ once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -otplib@^13.5.0: - version "13.5.0" - resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.5.0.tgz#78b5d0d9edf83078d7fe37243bbcdd96d7a65d37" - integrity sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ== +otplib@^13.6.0: + version "13.6.0" + resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.6.0.tgz#aa462e7e2c0370278666a608144c3194c7b2f7fe" + integrity sha512-PCx6+14PYndCRk0G3JuJHmlExzENs43gafqMUVmSFmzMbEweVhBiYcBCL8B32ZMEDPZA4OsMxFEeAob1H+YFZg== dependencies: - "@otplib/core" "13.5.0" - "@otplib/hotp" "13.5.0" - "@otplib/plugin-base32-scure" "13.5.0" - "@otplib/plugin-crypto-noble" "13.5.0" - "@otplib/totp" "13.5.0" - "@otplib/uri" "13.5.0" + "@otplib/core" "13.6.0" + "@otplib/hotp" "13.6.0" + "@otplib/plugin-base32-scure" "13.6.0" + "@otplib/plugin-crypto-noble" "13.6.0" + "@otplib/totp" "13.6.0" + "@otplib/uri" "13.6.0" p-limit@^1.1.0: version "1.3.0" @@ -1733,20 +1725,20 @@ path@^0.12.7: process "^0.11.1" util "^0.10.3" -pg-cloudflare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" - integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A== +pg-cloudflare@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz#0fc904c9a8b3ca3a4f71e16bcc9171224ecbb2a8" + integrity sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ== pg-connection-string@2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.2.tgz#713d82053de4e2bd166fab70cd4f26ad36aab475" integrity sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA== -pg-connection-string@^2.14.0: - version "2.14.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9" - integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg== +pg-connection-string@^2.14.1: + version "2.14.1" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.1.tgz#d9529fe2497c0cac8a9f5f6f059679c52f1bd964" + integrity sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA== pg-int8@1.0.1: version "1.0.1" @@ -1758,10 +1750,10 @@ pg-pool@^3.14.0: resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== -pg-protocol@^1.16.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.0.tgz#cffb008826561ee9770a8a15dc21f269d731b305" - integrity sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg== +pg-protocol@^1.16.1: + version "1.16.1" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.1.tgz#87a3951b57002682d63166c029859bd834e64bf1" + integrity sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg== pg-types@2.2.0: version "2.2.0" @@ -1774,18 +1766,18 @@ pg-types@2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@^8.23.0: - version "8.23.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.0.tgz#5c2026d32bd0cb4fbd9196bac1ecf5ae66607180" - integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg== +pg@^8.23.1: + version "8.23.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.1.tgz#e4848051a04aa2f968fd5671d075f89e5e7918b9" + integrity sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ== dependencies: - pg-connection-string "^2.14.0" + pg-connection-string "^2.14.1" pg-pool "^3.14.0" - pg-protocol "^1.16.0" + pg-protocol "^1.16.1" pg-types "2.2.0" pgpass "1.0.5" optionalDependencies: - pg-cloudflare "^1.4.0" + pg-cloudflare "^1.4.1" pgpass@1.0.5: version "1.0.5" @@ -1800,9 +1792,9 @@ picomatch@^2.0.4, picomatch@^2.2.1: integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" - integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== pify@^3.0.0: version "3.0.0" @@ -1872,10 +1864,10 @@ process@^0.11.1, process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== -proxy-addr@^2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== +proxy-addr@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.8.tgz#624e760fd8ab06b1b8320c2f8506d4422cf8e932" + integrity sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ== dependencies: forwarded "0.2.0" ipaddr.js "1.9.1" @@ -1917,10 +1909,10 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -qs@^6.14.0, qs@^6.15.2: - version "6.16.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" - integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== +qs@^6.16.0, qs@^6.16.1: + version "6.17.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.17.1.tgz#43ac71d29ca0e9d0eed16a218e35b6cde5628e81" + integrity sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA== dependencies: es-define-property "^1.0.1" side-channel "^1.1.1" @@ -1983,9 +1975,9 @@ readable-stream@^3.1.1, readable-stream@^3.4.0: util-deprecate "^1.0.1" readable-stream@^4.0.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" - integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== + version "4.8.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.8.0.tgz#22af894e6d60cb0064410fc376367ec4ce470adb" + integrity sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ== dependencies: abort-controller "^3.0.0" buffer "^6.0.3" @@ -2035,12 +2027,12 @@ resolve-from@^5.0.0: integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== resolve@^1.20.0: - version "1.22.12" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f" - integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA== + version "1.22.14" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.14.tgz#302388cd2d7731a100db1dedce7b050f74ba38f7" + integrity sha512-1ZjYvR9sbkiwC5+9QHetqW5HmA2pHRMaoupD01t2HlYnV/vydC2zPwLixhsMieSWVZt0JJRg5LVIk0Ow1D/TrA== dependencies: es-errors "^1.3.0" - is-core-module "^2.16.1" + is-core-module "^2.18.0" path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" @@ -2071,9 +2063,9 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: - version "7.8.5" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" - integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + version "7.8.6" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.6.tgz#67c0cda133a3ceb6f70b803e67625bf969000424" + integrity sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw== send@^1.1.0, send@^1.2.0: version "1.2.1" @@ -2197,9 +2189,9 @@ socks-proxy-agent@10.1.0: socks "^2.8.3" socks@^2.8.3: - version "2.8.9" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.9.tgz#aa5f130ca0f88a43fa44faf4869c50d22aa27752" - integrity sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw== + version "2.8.10" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.10.tgz#23aab4dccc1fdae110aaf4fa51cd190552432dc5" + integrity sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ== dependencies: ip-address "^10.1.1" smart-buffer "^4.2.0" @@ -2215,9 +2207,9 @@ split2@^4.1.0: integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== sql-escaper@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.5.1.tgz#fbaee511294d1f7da92fb68ca1faee2255839c7b" - integrity sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg== + version "1.5.2" + resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.5.2.tgz#5b59840dc4034bd23a9b7d00501ba947a90364b7" + integrity sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg== sqlite3@^6.0.1: version "6.0.1" @@ -2241,10 +2233,10 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: - version "2.28.0" - resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.0.tgz#035ab56057b7ed2211b51d532e6973f0f99fbf11" - integrity sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw== +streamx@^2.12.5, streamx@^2.25.0, streamx@^2.28.1: + version "2.28.1" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.1.tgz#376cd42a089505a69bec0efdb5189aa45586141f" + integrity sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA== dependencies: events-universal "^1.0.0" fast-fifo "^1.3.2" @@ -2324,14 +2316,14 @@ tar-stream@^2.1.4: readable-stream "^3.1.1" tar-stream@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.0.tgz#0d0064d9b67ea3c9f5abde155e35faab0df37591" - integrity sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg== + version "3.2.2" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.2.tgz#aec7f257e712192a177a93aa0450c2e76855c8be" + integrity sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg== dependencies: - b4a "^1.6.4" - bare-fs "^4.5.5" - fast-fifo "^1.2.0" - streamx "^2.15.0" + b4a "^1.9.0" + bare-fs "^4.8.2" + fast-fifo "^1.3.2" + streamx "^2.28.1" tar@^7.5.10, tar@^7.5.4: version "7.5.22" @@ -2408,9 +2400,9 @@ touch@^3.1.0: integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== tslib@^2.0.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + version "2.9.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.9.1.tgz#8986213ba2f17f38ba39a7c43d0e565a9134ff5f" + integrity sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA== tunnel-agent@^0.6.0: version "0.6.0" @@ -2419,7 +2411,7 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -type-is@^2.0.1, type-is@^2.1.0: +type-is@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== @@ -2434,14 +2426,9 @@ undefsafe@^2.0.5: integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== undici@^6.25.0: - version "6.28.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-6.28.0.tgz#9f0e385744fef5021d6596c5bccd783f61193c1c" - integrity sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA== - -undici@^8.10.2: - version "8.10.2" - resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.2.tgz#960bcb0b43c267f86910ea7ca408ada843a44bc7" - integrity sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ== + version "6.29.1" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.29.1.tgz#a3317c5df0236e0633727d9a7bd4f7ed195d8575" + integrity sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA== unpipe@~1.0.0: version "1.0.0" diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml index be6f517d3..2aef4e8ff 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -37,6 +37,7 @@ services: - website1.example.com - website2.example.com - website3.example.com + - website4.example.com examplesite: image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index d6b07ec01..20e01e6be 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -16,6 +16,7 @@ services: - website1.example.com - website2.example.com - website3.example.com + - website4.example.com environment: TZ: "${TZ:-Australia/Brisbane}" PUID: 1000 diff --git a/docker/rootfs/etc/nginx/conf.d/dev.conf b/docker/rootfs/etc/nginx/conf.d/dev.conf index 67efc0f8a..57008f9a0 100644 --- a/docker/rootfs/etc/nginx/conf.d/dev.conf +++ b/docker/rootfs/etc/nginx/conf.d/dev.conf @@ -17,6 +17,9 @@ server { proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; + # The TCP peer before realip processing. In development there is no peer secret + # (see production.conf.template), so the backend ignores this and limits on the socket + proxy_set_header X-NPM-Peer-Addr $realip_remote_addr; proxy_pass http://127.0.0.1:3000/; proxy_read_timeout 15m; diff --git a/docker/rootfs/etc/nginx/conf.d/include/assets.conf b/docker/rootfs/etc/nginx/conf.d/include/assets.conf index 5a90beb8a..9157d172d 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/assets.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/assets.conf @@ -3,7 +3,7 @@ location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map| # use the public cache proxy_cache public-cache; - proxy_cache_key $host$request_uri; + proxy_cache_key $forward_host$request_uri; # ignore these headers for media proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires; diff --git a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf index fe2c2f213..fdba1bd7c 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf @@ -1,5 +1,5 @@ add_header X-Served-By $host; -proxy_set_header Host $host; +proxy_set_header Host $forward_host; proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme; proxy_set_header X-Forwarded-Proto $x_forwarded_proto; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docker/rootfs/etc/nginx/conf.d/production.conf.template b/docker/rootfs/etc/nginx/conf.d/production.conf.template index 452609bcb..be7aca196 100644 --- a/docker/rootfs/etc/nginx/conf.d/production.conf.template +++ b/docker/rootfs/etc/nginx/conf.d/production.conf.template @@ -17,6 +17,11 @@ server { proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; + # The TCP peer before realip processing; the backend rate limits on this because + # X-Real-IP can be set by any client in the trusted private ranges. The secret, generated + # at startup, is how the backend knows this header came from here. + proxy_set_header X-NPM-Peer-Addr $realip_remote_addr; + proxy_set_header X-NPM-Peer-Secret "{{NPM_PEER_SECRET}}"; proxy_pass http://127.0.0.1:3000/; proxy_read_timeout 15m; diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index bdba3b305..1b25cb5fc 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -52,6 +52,18 @@ http { # Dynamically generated resolvers file include /etc/nginx/conf.d/include/resolvers[.]conf; + # Forwarded Host: nginx's normalised $host (lowercased, taken from the request line for + # absolute-form requests) plus the client's port, so upstreams that compare Host with + # Origin/Referer on non-standard ports keep working. The port is only carried over when + # the Host header names the same host nginx routed on; otherwise (no Host, absolute-form + # URI naming a different host) it falls back to bare $host. Using $host rather than the + # raw header keeps case variants and mismatched Host values out of upstream requests and + # the asset cache key. + map "$host|$http_host" $forward_host { + "~*^(?[^|]+)\|\k(?:[0-9]+)$" "$host$fwd_port"; + default $host; + } + # Default upstream scheme map $host $forward_scheme { default http; diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh index 2f59ef41a..ebbcd958c 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh @@ -39,3 +39,7 @@ touch /var/log/nginx/error.log || true chmod 777 /var/log/nginx/error.log || true chmod -R 777 /var/cache/nginx || true chmod 644 /etc/logrotate.d/nginx-proxy-manager + +# nginx has not been started yet, so any pid file here is stale. Remove it, +# otherwise a pid file owned by another user stops nginx starting as PUID:PGID +rm -f /run/nginx/nginx.pid diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh index 4236c90a2..1306e7035 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh @@ -49,7 +49,9 @@ done # Ensure the JWT key file is owned by the runtime user, even when the /data # directory ownership already matches PUID:PGID (chownit skips recursion then) if [ -f /data/keys.json ]; then - chown "$PUID:$PGID" /data/keys.json + if [ "$(stat -c '%u:%g' '/data/keys.json')" != "$PUID:$PGID" ]; then + chown "$PUID:$PGID" /data/keys.json + fi fi if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh index 3807c7d76..32252c727 100644 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh @@ -16,7 +16,16 @@ if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then fi PRODFILE="/etc/nginx/conf.d/production.conf" -SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g" + +# A new secret on every start. The admin nginx sends it with X-NPM-Peer-Addr, and the backend +# only trusts that header when the secret matches, so a proxy host or stream forwarding to the +# backend port can't pass a forged one through. +NPM_PEER_SECRET="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" +if [ "${#NPM_PEER_SECRET}" -ne 64 ]; then + log_fatal "Failed to generate the backend peer secret" +fi + +SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g;s/\{\{NPM_PEER_SECRET\}\}/${NPM_PEER_SECRET}/g" if is_mounted "$PRODFILE"; then echo "WARNING: skipping ${PRODFILE} — mounted file" >&2 @@ -24,6 +33,8 @@ elif [ -f "$PRODFILE.template" ]; then if sed -E "$SED_REGEX" "$PRODFILE.template" > "$PRODFILE" && [ -s "$PRODFILE" ]; then # success log_info "Generated ${PRODFILE} from template" + # Hand the secret to the backend; with-contenv loads this directory into its environment + printf '%s' "$NPM_PEER_SECRET" > /var/run/s6/container_environment/NPM_PEER_SECRET else log_fatal "Failed to generate ${PRODFILE} from template" fi diff --git a/docker/scripts/install-s6 b/docker/scripts/install-s6 index 55513fe1e..dc8eb4ae2 100755 --- a/docker/scripts/install-s6 +++ b/docker/scripts/install-s6 @@ -8,7 +8,7 @@ BLUE='\E[1;34m' GREEN='\E[1;32m' RESET='\E[0m' -S6_OVERLAY_VERSION=3.2.3.0 +S6_OVERLAY_VERSION=3.2.3.2 TARGETPLATFORM=${1:-linux/amd64} # Determine the correct binary file for the architecture given diff --git a/docs/yarn.lock b/docs/yarn.lock index f8c7568b2..39af0fc23 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2,15 +2,15 @@ # yarn lockfile v1 -"@algolia/abtesting@1.12.2": - version "1.12.2" - resolved "https://registry.yarnpkg.com/@algolia/abtesting/-/abtesting-1.12.2.tgz#1cba5e3c654d02c6d435822a0a0070a5c435daa6" - integrity sha512-oWknd6wpfNrmRcH0vzed3UPX0i17o4kYLM5OMITyMVM2xLgaRbIafoxL0e8mcrNNb0iORCJA0evnNDKRYth5WQ== +"@algolia/abtesting@1.27.0": + version "1.27.0" + resolved "https://registry.yarnpkg.com/@algolia/abtesting/-/abtesting-1.27.0.tgz#5f507d696271fec578269693faf1adf383c05eb8" + integrity sha512-XXm0DTCmSzHT40mn0Hs8H00pBcuaqQET0BsWfaHrQdijNfrhSZPLueGVQvYAeSeXVuS420arotSfgqY25nFNbA== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" "@algolia/autocomplete-core@1.17.7": version "1.17.7" @@ -39,146 +39,146 @@ resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz#105e84ad9d1a31d3fb86ba20dc890eefe1a313a0" integrity sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg== -"@algolia/client-abtesting@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.46.2.tgz#264a72f0e9d2fe0d0dc5c3d2d16bbb9cfe2ce9e8" - integrity sha512-oRSUHbylGIuxrlzdPA8FPJuwrLLRavOhAmFGgdAvMcX47XsyM+IOGa9tc7/K5SPvBqn4nhppOCEz7BrzOPWc4A== +"@algolia/client-abtesting@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.61.0.tgz#94c4365c9c206d6f314d04f66ccdb48661c111cf" + integrity sha512-NSq61VNhN63nstgbEh2YzygmtybxPnBdFwmjPOa7laJp8RapLrkqwHUaI4ELCyuZMhcuRlUvOL25y+SDVkgnsA== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/client-analytics@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.46.2.tgz#3f00a237508aa0c46c9c02dea9c855e0a78e241f" - integrity sha512-EPBN2Oruw0maWOF4OgGPfioTvd+gmiNwx0HmD9IgmlS+l75DatcBkKOPNJN+0z3wBQWUO5oq602ATxIfmTQ8bA== +"@algolia/client-analytics@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.61.0.tgz#83324527f13047669c2de59099cd811e31e5f8dc" + integrity sha512-WBMStmOLas4bWHh0rgOOb8iTQL07fsfPwvEW2vrQL+5v2KWMcRix9cYSQp3SOGjdweNjUszbGO84VBBMHBnHbQ== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/client-common@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.46.2.tgz#7f282fd8f721b0d96958445df2170f4c7dce6aac" - integrity sha512-Hj8gswSJNKZ0oyd0wWissqyasm+wTz1oIsv5ZmLarzOZAp3vFEda8bpDQ8PUhO+DfkbiLyVnAxsPe4cGzWtqkg== +"@algolia/client-common@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.61.0.tgz#053b7bded65eafc13d709f0ad23b6956561c8f5f" + integrity sha512-mzrW0KkEiZHB09cU2Sh2vyxUGSi9fVoglCeKnf3wJ3ixo+7ilHH64wLK45eSoX13+CVXA2KTjZJGcEmBS3Z57Q== -"@algolia/client-insights@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.46.2.tgz#194b7b529ee8a4ffd5d70037745082996c3b9aa0" - integrity sha512-6dBZko2jt8FmQcHCbmNLB0kCV079Mx/DJcySTL3wirgDBUH7xhY1pOuUTLMiGkqM5D8moVZTvTdRKZUJRkrwBA== +"@algolia/client-insights@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.61.0.tgz#fe5c960ae71c561e933e959a464e6d9a62e5a791" + integrity sha512-TseZAUL8ZUtKQY6MQbJR/Qw9OSf54s9gvcDtQ7GZX9miZD1e24E1/usa1lGrrgq5KDpgQ8+ePCQIITFcq0Y56Q== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/client-personalization@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.46.2.tgz#d604da7f0a3df1b3e2a9fe338d368e48fb781f8e" - integrity sha512-1waE2Uqh/PHNeDXGn/PM/WrmYOBiUGSVxAWqiJIj73jqPqvfzZgzdakHscIVaDl6Cp+j5dwjsZ5LCgaUr6DtmA== +"@algolia/client-personalization@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.61.0.tgz#c2b39d43d845181658f4f1731ad9b11280a1023b" + integrity sha512-4CBpVN2WDVLocAzMU8me5UHWSbf6oXI7KDy3K0pL/8i0eB1+nOMQBU7jmUL6kBl2XMoYAu6tnsZmxpZ59b+Y2g== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/client-query-suggestions@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.46.2.tgz#f13bc5897bfbdc19509d430a26e9bbe2402e00c9" - integrity sha512-EgOzTZkyDcNL6DV0V/24+oBJ+hKo0wNgyrOX/mePBM9bc9huHxIY2352sXmoZ648JXXY2x//V1kropF/Spx83w== +"@algolia/client-query-suggestions@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.61.0.tgz#fd70402c32bb3ed4e8bba4a8b89689672f99cdfc" + integrity sha512-PU2XREmx7DdZ6SjuuaM+ZwhfMFOUPXjLeUYyvxTFA8oz06yrY/wcn5V/UOMv01rdeTuG2petMZLzTJADF9KyEw== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/client-search@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.46.2.tgz#771367916aaa3fb7a19d5944f8375504b0568ba6" - integrity sha512-ZsOJqu4HOG5BlvIFnMU0YKjQ9ZI6r3C31dg2jk5kMWPSdhJpYL9xa5hEe7aieE+707dXeMI4ej3diy6mXdZpgA== +"@algolia/client-search@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.61.0.tgz#e120030ae546407015bd52e0c73185f8c562de44" + integrity sha512-npseUJ0UwaiwU4XbKyHiFcZbrS1AbLszbPRxi3W9YUrrqdpvfSeGKMlLv63haH4PZsCog5wG40lJGKXbnrDAEQ== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/ingestion@1.46.2": - version "1.46.2" - resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.46.2.tgz#2a5d8a592d9f864dfb438722506382af56f8554f" - integrity sha512-1Uw2OslTWiOFDtt83y0bGiErJYy5MizadV0nHnOoHFWMoDqWW0kQoMFI65pXqRSkVvit5zjXSLik2xMiyQJDWQ== +"@algolia/ingestion@1.61.0": + version "1.61.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.61.0.tgz#ee7fc87d06aff23f1b6f5c30fd352d271cd0cf73" + integrity sha512-rA/W43fD2qQemeQ2/uix/KSEUdikY55RTmgbfBRoF9HjCJ/zryD9RAKJXWxBlFXRBg9q8EPPYH3GlSXcRHnpgg== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/monitoring@1.46.2": - version "1.46.2" - resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.46.2.tgz#bd199368a49cb799cf12cfe76c49de6dd3021148" - integrity sha512-xk9f+DPtNcddWN6E7n1hyNNsATBCHIqAvVGG2EAGHJc4AFYL18uM/kMTiOKXE/LKDPyy1JhIerrh9oYb7RBrgw== +"@algolia/monitoring@1.61.0": + version "1.61.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.61.0.tgz#ad3e3b31ab078b852b07ee7f8e95a08be7201391" + integrity sha512-3CEPgD95sBitQVQsPXmIfMkcX0Kh12d5wmI7iOm1yrllPBu4Vx30Q2xMv9wyHm2C2bHQwz+KPBohtq6Hh1K5eg== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/recommend@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.46.2.tgz#e74bade1254046ed9be8ccd37f2a116ab9799508" - integrity sha512-NApbTPj9LxGzNw4dYnZmj2BoXiAc8NmbbH6qBNzQgXklGklt/xldTvu+FACN6ltFsTzoNU6j2mWNlHQTKGC5+Q== +"@algolia/recommend@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.61.0.tgz#de30dfd0799aa5eb6f50c47cb745bb830ea99b7a" + integrity sha512-2pCOG8rRFW+0OeUmNUMoRH5xXktCEdUqtjuz6FCFQa1lxqPIUBr4HHIneXTIuAgUC0ckokqBCZk0v5qU7DvBuA== dependencies: - "@algolia/client-common" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/client-common" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" -"@algolia/requester-browser-xhr@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.46.2.tgz#7662480143405e815e1eed99136b4b2acd838ee7" - integrity sha512-ekotpCwpSp033DIIrsTpYlGUCF6momkgupRV/FA3m62SreTSZUKjgK6VTNyG7TtYfq9YFm/pnh65bATP/ZWJEg== +"@algolia/requester-browser-xhr@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.61.0.tgz#57abc51ff771929f3a08a3c79ea1cfa7aa52e870" + integrity sha512-7Y7P6Kp+7DQkrOCzmIYjoCpLPQoA8vlxcIzyjpfMrokBNX0LIQSPyR5JY/EdSzCN6CvNlYGJ5SiO7j6lZad5Jw== dependencies: - "@algolia/client-common" "5.46.2" + "@algolia/client-common" "5.61.0" -"@algolia/requester-fetch@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.46.2.tgz#dee07f0131b75f30d083bafd6fb878afe7402eb9" - integrity sha512-gKE+ZFi/6y7saTr34wS0SqYFDcjHW4Wminv8PDZEi0/mE99+hSrbKgJWxo2ztb5eqGirQTgIh1AMVacGGWM1iw== +"@algolia/requester-fetch@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.61.0.tgz#e7a2cf2555259cc6d27c47778424f7d90c6e4235" + integrity sha512-WNTNQcrWcGAgLDU3+6IFWs3QPosMCYSlwlPEdEO2ivLnlm8u5HtGQvOz/JnY9d2bhofDBL+S5wdtm9+Uob+dsA== dependencies: - "@algolia/client-common" "5.46.2" + "@algolia/client-common" "5.61.0" -"@algolia/requester-node-http@5.46.2": - version "5.46.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.46.2.tgz#7869d67cb2926bbdbfbfed2b4757e547c2e227eb" - integrity sha512-ciPihkletp7ttweJ8Zt+GukSVLp2ANJHU+9ttiSxsJZThXc4Y2yJ8HGVWesW5jN1zrsZsezN71KrMx/iZsOYpg== +"@algolia/requester-node-http@5.61.0": + version "5.61.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.61.0.tgz#5b9c56c2d09bc12bbd29c318c08804dd394f6fc0" + integrity sha512-CA+6MlgPkjSlGQpx+rLBSnHFy8pyYpGEjs7Yp0kVzOC6RM5jq99Z4KGOuplcNZBjQlM521t0nEvKLxFOv2NSZA== dependencies: - "@algolia/client-common" "5.46.2" + "@algolia/client-common" "5.61.0" -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.29.11": + version "7.29.13" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz#00a43cc19b8f84e9e3f17f32813e69ae9bfceba8" + integrity sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ== -"@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== -"@babel/parser@^7.28.5": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" - integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== +"@babel/parser@^7.29.12": + version "7.29.13" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.13.tgz#dbba85be72b94635123d594035fd087fe7e42bb3" + integrity sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ== dependencies: - "@babel/types" "^7.28.6" + "@babel/types" "^7.29.11" -"@babel/types@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" - integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== +"@babel/types@^7.29.11": + version "7.29.11" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.11.tgz#774cffe8cac2ca494f1334e4052002c96bf628fc" + integrity sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ== dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" + "@babel/helper-string-parser" "^7.29.11" + "@babel/helper-validator-identifier" "^7.29.7" "@docsearch/css@3.8.2": version "3.8.2" @@ -319,9 +319,9 @@ integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== "@iconify-json/simple-icons@^1.2.21": - version "1.2.66" - resolved "https://registry.yarnpkg.com/@iconify-json/simple-icons/-/simple-icons-1.2.66.tgz#d0576ca65f69616b49491129e17132316cf3f309" - integrity sha512-D1OnnXwiQXFkVMw5M+Bt8mPsXeMkQyGmMdrmN7lsQlKMUkfLOp6JWhnUJ92po51WXT046aF/zzqSmkKqg08p4Q== + version "1.2.101" + resolved "https://registry.yarnpkg.com/@iconify-json/simple-icons/-/simple-icons-1.2.101.tgz#323b81ed81d282dbf95eb39329b27e1e398243a0" + integrity sha512-MXJkMZDancJ/9I+Bgdthi9kXvn3AwWStxoTHwEPwAFQ9kMLBASO1BSstg52fNtjXeizm1Wi91R+QnWxNGkWHPQ== dependencies: "@iconify/types" "*" @@ -331,134 +331,139 @@ integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== "@jridgewell/sourcemap-codec@^1.5.5": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" - integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" + integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== -"@rollup/rollup-android-arm-eabi@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz#a6742c74c7d9d6d604ef8a48f99326b4ecda3d82" - integrity sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg== +"@rollup/rollup-android-arm-eabi@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.68.0.tgz#c5a058efb08e5119d3ff95b443d1b4661afba767" + integrity sha512-Ta2n0ntLfzP0LqwwcXOIh2z0mnHjlxMS+h4Y9lo+w33Quyf9KFKEi9QZ2IKqeEBKr0qxNpXc/DQjz5KVIs8AEA== -"@rollup/rollup-android-arm64@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz#97247be098de4df0c11971089fd2edf80a5da8cf" - integrity sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q== +"@rollup/rollup-android-arm64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.68.0.tgz#3e77dc3d6f7b9fbab6c76ef8a3f1cf69bb2a185b" + integrity sha512-iFDtS0oMaBvKbtrRKXmRcBSsI0UaWL2bWpd62iHzb0A8RSPrVz47mUGE0CfrrD0c/7/7dUhNtoN3cc6aEUn1Ow== -"@rollup/rollup-darwin-arm64@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz#674852cf14cf11b8056e0b1a2f4e872b523576cf" - integrity sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg== +"@rollup/rollup-android-x64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-x64/-/rollup-android-x64-4.68.0.tgz#fd2a20f0dd028df151003f1542a3a0504c33d297" + integrity sha512-OnYETr+cZYSbesBD1tCvUjrBUTkjSaOcewRUoIHqD0XI3yn5i9WpdFaE3LZwrMlq0yeJNU0N5sxfHK/fNVMqlQ== -"@rollup/rollup-darwin-x64@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz#36dfd7ed0aaf4d9d89d9ef983af72632455b0246" - integrity sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w== +"@rollup/rollup-darwin-arm64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.68.0.tgz#6dd9ce3f0b148344b87ffb0b0fe3168c87fcacd1" + integrity sha512-2qavWZtFeon/6oMkjsBF3OH7ENKgRTW4xRTwmz9VJeNUYhmPitpR9Jd4sAAc2OZJryHs2neoovMTCYnNvhvANQ== -"@rollup/rollup-freebsd-arm64@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz#2f87c2074b4220260fdb52a9996246edfc633c22" - integrity sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA== +"@rollup/rollup-darwin-x64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.68.0.tgz#78092241c8f6860b2275cd0bc7c5bdbbce4d572f" + integrity sha512-QpgJO+GwNQDPt6uYcFSjZTFVyHNIeumN2am7RISDR9Nf3e/h8sur9cIfplPGihZPUn+sTw9j3JErZIUkHg8+Pw== -"@rollup/rollup-freebsd-x64@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz#9b5a26522a38a95dc06616d1939d4d9a76937803" - integrity sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg== +"@rollup/rollup-freebsd-arm64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.68.0.tgz#fefaf85dad7595379f417b24a200c3da7fc7f802" + integrity sha512-TERqEn6P9bp4lsTyoFjT3nLKV6G++76HhGb4k6dRGcs+jvhVcTGaS2zkeRdLLRjwKxpBtdCZe11kUQ2hNGgFSA== -"@rollup/rollup-linux-arm-gnueabihf@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz#86aa4859385a8734235b5e40a48e52d770758c3a" - integrity sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw== +"@rollup/rollup-freebsd-x64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.68.0.tgz#c58f4619f49ed299e99bd72df0c8e9bb9bad1cda" + integrity sha512-RBjDrqb7h5l6Xgdgz5eKYvbpnUkii/z5ejWuZmHJsXORi0X0tTtFzEEgJj2DX995kW18c6T9LGFn/deEAMXDcQ== -"@rollup/rollup-linux-arm-musleabihf@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz#cbe70e56e6ece8dac83eb773b624fc9e5a460976" - integrity sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA== +"@rollup/rollup-linux-arm-gnueabihf@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.68.0.tgz#630fcb12dcdeae9e7a4d26046e8de8d07cc6501c" + integrity sha512-HtLgXBqjF1Ampt9eJjzSmoGDvNb4LP8Y2aPXQZeQ5qhyExXLmoXJB9Jfm/nPi/8fJ3UFn4sZ7pQKyr8V+oTN8g== -"@rollup/rollup-linux-arm64-gnu@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz#d14992a2e653bc3263d284bc6579b7a2890e1c45" - integrity sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA== +"@rollup/rollup-linux-arm-musleabihf@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.68.0.tgz#da75938ef398e7907af4e8723af970a04387bd56" + integrity sha512-q51xJyqjaqCNtDjYD7xNVwsPa2TcVfAIBeP6E20wicKZ6XYp8Taz9SUPYhIENojAQIAoV7cO3mUdMUgWYEAuhQ== -"@rollup/rollup-linux-arm64-musl@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz#2fdd1ddc434ea90aeaa0851d2044789b4d07f6da" - integrity sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA== +"@rollup/rollup-linux-arm64-gnu@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.68.0.tgz#1aae89d808ca937a4fad7b28e4008e910dee4923" + integrity sha512-wejnVabl92oFC/o4sr/EEJuJZMHtMq4C+LBIPphN4EUiVHe9qvZNw+biqhVHpX2ggs1+Z3ugXGwJxT+qhyaaYQ== -"@rollup/rollup-linux-loong64-gnu@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz#8a181e6f89f969f21666a743cd411416c80099e7" - integrity sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg== +"@rollup/rollup-linux-arm64-musl@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.68.0.tgz#f70fce082bd03ed7729dd87c62f4eb014443f5f6" + integrity sha512-tyu+hrsCWHHqV6+2tligV/recJH+HVvNV2Sp4nS1C+MKQPkNXRovKIzyHy0V0xXjm6igCLgsT2vTky5CvWP37g== -"@rollup/rollup-linux-loong64-musl@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz#904125af2babc395f8061daa27b5af1f4e3f2f78" - integrity sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q== +"@rollup/rollup-linux-loong64-gnu@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.68.0.tgz#70eaf986a324ee721ca3a647f5aeda6b29196533" + integrity sha512-5gjJNglKDfgs3OmnGi/RVZQPhEfRoCIHxwSAP9ngmdnxreu0Vl9lI4gxmFBXOhKdftpS82xRdc/7Dp5/A1F0qg== -"@rollup/rollup-linux-ppc64-gnu@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz#a57970ac6864c9a3447411a658224bdcf948be22" - integrity sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA== +"@rollup/rollup-linux-loong64-musl@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.68.0.tgz#3667cf43e4bbd9254e6c12004eb02e110e67ad69" + integrity sha512-2IBxTUds33rFYwOIEyf77wfW8WQThwiQQ1ofXSraZQG7PiIq7BnQBf/ge0qEI/Ny/2cSc7SKkGO/wUDqmYXqTg== -"@rollup/rollup-linux-ppc64-musl@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz#bb84de5b26870567a4267666e08891e80bb56a63" - integrity sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA== +"@rollup/rollup-linux-ppc64-gnu@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.68.0.tgz#c04f13264b418b39374d255ccffb6cb1f4fc9039" + integrity sha512-mE/Pj2PPTZIVHkNNuHq+eYoU3td8LCm73zjrXoBWkNlnuULJS2l/fxwgGPpN4WUCNZgKZ1IXwrnnETPyOcWPmg== -"@rollup/rollup-linux-riscv64-gnu@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz#72d00d2c7fb375ce3564e759db33f17a35bffab9" - integrity sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg== +"@rollup/rollup-linux-ppc64-musl@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.68.0.tgz#d54bcd8beddff5cf778c4021be317b48813b4327" + integrity sha512-dYuykUkAiTv+RQdP3Ib47XcFiImkBWiRpTGSQWAMfZPQyoQYGe8Je4IUeZYNUQWIojmRCUnt5xsH4BbUb2uqSw== -"@rollup/rollup-linux-riscv64-musl@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz#4c166ef58e718f9245bd31873384ba15a5c1a883" - integrity sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg== +"@rollup/rollup-linux-riscv64-gnu@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.68.0.tgz#6d2c020ef52e437115bd3eb9802bebded8b7245e" + integrity sha512-OnPDDFXLqJewY56T6ow8sA4wuzbqlliD7QJgkrx2KX+pWOpjI3usYXgB0pwK3nsgTF2odCw9IuJs0Uis/jy2qA== -"@rollup/rollup-linux-s390x-gnu@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz#bb5025cde9a61db478c2ca7215808ad3bce73a09" - integrity sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w== +"@rollup/rollup-linux-riscv64-musl@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.68.0.tgz#2013db95988d9a8bbf533f752621406d3b6335f0" + integrity sha512-CqoQf06cK0brrRfDqH7Mt8G4MdteFGbjBdC61zEMaSVna6xo6QMUixa9bAkAD4a92g3ElPbQAkLMUzdf+Lk8dg== -"@rollup/rollup-linux-x64-gnu@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz#9b66b1f9cd95c6624c788f021c756269ffed1552" - integrity sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg== +"@rollup/rollup-linux-s390x-gnu@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.68.0.tgz#59333d3c2104c649abbf6d9c98382aca4b7aff65" + integrity sha512-LPHNmE8GwiyELGLUTkqj4zsP1YEHzGdtwnKxAcdP/swsNxXsfxPtpe5nrD/KGxr136xPB+eWv2r/SGj9md0pnQ== -"@rollup/rollup-linux-x64-musl@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz#b007ca255dc7166017d57d7d2451963f0bd23fd9" - integrity sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg== +"@rollup/rollup-linux-x64-gnu@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.68.0.tgz#def6dd537d19c05834f42d2cc14ce6e4462a6d52" + integrity sha512-GDOmvgephpRCNyNGRQOZh0KD2gTyRVM1Bb1iOo759xeCUpCAoRCmJioOOSu2MwyWfNOocGd0V5I9vYLfteC6/A== -"@rollup/rollup-openbsd-x64@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz#e8b357b2d1aa2c8d76a98f5f0d889eabe93f4ef9" - integrity sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ== +"@rollup/rollup-linux-x64-musl@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.68.0.tgz#4c4e4673d23b301d9d26076983456183966b52e5" + integrity sha512-HADRxkvl5eg3hbemCmv81UwTfdQy/CiSJDOabLE+p5tpm3OTjuRtzpLXy5zPfGY9FQD4Qv7qHptXmG/YjmNmyw== -"@rollup/rollup-openharmony-arm64@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz#96c2e3f4aacd3d921981329831ff8dde492204dc" - integrity sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA== +"@rollup/rollup-openbsd-x64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.68.0.tgz#7ca2e43427d49333cb211c4d1980f07e6b576d10" + integrity sha512-iERrpqu1VEfyX736HLKiXRbwGDkCV/iybARY/mn14KcI6oKhJGBx1e+bU+g+umTKghtiGSPaHkwwpII4/N3RjA== -"@rollup/rollup-win32-arm64-msvc@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz#2d865149d706d938df8b4b8f117e69a77646d581" - integrity sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A== +"@rollup/rollup-openharmony-arm64@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.68.0.tgz#899bf838d15a8c69174a36886ce8fc55402b8d31" + integrity sha512-hBn57IqAbpXpyktckonwePTaFOQS6THph8ZnpRO3eZHY4LncuZGjSsseigBnKuSMKKpLnJJ7PemDvXEzniXC+A== -"@rollup/rollup-win32-ia32-msvc@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz#abe1593be0fa92325e9971c8da429c5e05b92c36" - integrity sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA== +"@rollup/rollup-win32-arm64-msvc@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.68.0.tgz#216ca59394b95ccbba5e1ab5b563112198335b0f" + integrity sha512-pJWb7mIw9Z25Hl3Q6nHFjl7AnczfOpm2gbOFYguSRv6TiH7DzsoAk/thymb/URSQu9OUEK7uaDMC5Xfm+7TAww== -"@rollup/rollup-win32-x64-gnu@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz#c4af3e9518c9a5cd4b1c163dc81d0ad4d82e7eab" - integrity sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA== +"@rollup/rollup-win32-ia32-msvc@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.68.0.tgz#d96453b50d0f212facf6d8cd3e9fa390f1ce5665" + integrity sha512-pnrhmoyF2otH2Mezol14/ELUFGuRddOCz6+I+J0pq2Itnp4UxkjABQcXawCNMaKNsCRiuL90BtNzbEd0fHjpIg== -"@rollup/rollup-win32-x64-msvc@4.59.0": - version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz#4584a8a87b29188a4c1fe987a9fcf701e256d86c" - integrity sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== +"@rollup/rollup-win32-x64-gnu@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.68.0.tgz#2ee1a6f01e587e7dafa08fdd586dc710341a63e4" + integrity sha512-1dJ3U+ktdtaIdPaOZKwIX57+bKQk4iAjs5M1wQWLKRB6Wglp98qf8LUeuN4u6bGlIYouZKaySZJ5Qd4OalUMnA== + +"@rollup/rollup-win32-x64-msvc@4.68.0": + version "4.68.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.68.0.tgz#3d82b1bb57754a15cd05a5223189cdd3e6e96bcd" + integrity sha512-e/NRJFl48wL5DFNSzQrvm4kaB5dzNNmqWjIJ6And2Sjuw4klO3w3TXMvCAEF1tHTrQQgpOD4QVkBE/92/yBGrQ== "@shikijs/core@2.5.0", "@shikijs/core@^2.1.0": version "2.5.0" @@ -520,19 +525,19 @@ "@types/hast" "^3.0.4" "@shikijs/vscode-textmate@^10.0.2": - version "10.0.2" - resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" - integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg== + version "10.1.0" + resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.1.0.tgz#de59840c1d9903b5f5732dbc7852437003d35f11" + integrity sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ== -"@types/estree@1.0.8": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" - integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/estree@^1.0.10": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.10.tgz#8a4a266065af8c2fcf5983153cd97c11b313d0c6" + integrity sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g== "@types/hast@^3.0.0", "@types/hast@^3.0.4": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" - integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.5.tgz#48020de4c0e63492f4ca9db42068c108f68b7f8f" + integrity sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g== dependencies: "@types/unist" "*" @@ -542,9 +547,9 @@ integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== "@types/markdown-it@^14.1.2": - version "14.1.2" - resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" - integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== + version "14.2.0" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.2.0.tgz#4074873f98fbc791131e31d7446b12dfa3a31df9" + integrity sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ== dependencies: "@types/linkify-it" "^5" "@types/mdurl" "^2" @@ -572,70 +577,70 @@ integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== "@ungap/structured-clone@^1.0.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" - integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + version "1.4.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.4.0.tgz#5e2e1374c0a30b5a42e8b083523a225c6945f88a" + integrity sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ== "@vitejs/plugin-vue@^5.2.1": version "5.2.4" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz#9e8a512eb174bfc2a333ba959bbf9de428d89ad8" integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== -"@vue/compiler-core@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz#1a91ea90980528bedff7b1c292690bfb30612485" - integrity sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w== +"@vue/compiler-core@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.45.tgz#90c17a54f36dcc44f03ffdcacaca08110a89f255" + integrity sha512-XGHJrM6ufZARAc/w+tyOswStw9tdKiqCKBScI4gGaNxD+aaGu/HQWgKJ+JItjRLPqenQ7iE7G8AJC7T5ew1p+g== dependencies: - "@babel/parser" "^7.28.5" - "@vue/shared" "3.5.26" - entities "^7.0.0" + "@babel/parser" "^7.29.12" + "@vue/shared" "3.5.45" + entities "^7.0.1" estree-walker "^2.0.2" - source-map-js "^1.2.1" + source-map-js "^1.2.2" -"@vue/compiler-dom@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz#66c36b6ed8bdf43236d7188ea332bc9d078eb286" - integrity sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A== +"@vue/compiler-dom@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.45.tgz#d2a9d7655c53a93081c88220c385fce676859a6a" + integrity sha512-LbP2d19GSOYGPmvMBBCVJVlv7TKtjM1Cfeyu3cYia720pknZP+1cLX7H5MVatvruT6MuJhiPuNbQBW/MDn/EwA== dependencies: - "@vue/compiler-core" "3.5.26" - "@vue/shared" "3.5.26" + "@vue/compiler-core" "3.5.45" + "@vue/shared" "3.5.45" -"@vue/compiler-sfc@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz#fb1c6c4bf9a9e22bb169e039e19437cb6995917a" - integrity sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA== +"@vue/compiler-sfc@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.45.tgz#82cc47d45f60358c0b1b75a8d5f1ec837ed13e26" + integrity sha512-MKFjb2UX0M/l/jhfzqMLrI8VM4pfvFKG3pqFZkSZFHRLtNld/O4RtUPZFWSwhro/14qP88UQEkWzHxAZAQ0tsw== dependencies: - "@babel/parser" "^7.28.5" - "@vue/compiler-core" "3.5.26" - "@vue/compiler-dom" "3.5.26" - "@vue/compiler-ssr" "3.5.26" - "@vue/shared" "3.5.26" + "@babel/parser" "^7.29.12" + "@vue/compiler-core" "3.5.45" + "@vue/compiler-dom" "3.5.45" + "@vue/compiler-ssr" "3.5.45" + "@vue/shared" "3.5.45" estree-walker "^2.0.2" magic-string "^0.30.21" - postcss "^8.5.6" - source-map-js "^1.2.1" + postcss "^8.5.32" + source-map-js "^1.2.2" -"@vue/compiler-ssr@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz#f6e94bccbb5339180779036ddfb614f998a197ea" - integrity sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw== +"@vue/compiler-ssr@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.45.tgz#a760c005b20a62fc2babb16bd5c45ceab2d6595d" + integrity sha512-RF5sbZOfYz5AK00tOJUn2PwFXR2fS1u2HVzI8D9VF9rGAFxuGBqg2bB1kJ3ZHMprqSltXnT9MZE1IsqjSM8oew== dependencies: - "@vue/compiler-dom" "3.5.26" - "@vue/shared" "3.5.26" + "@vue/compiler-dom" "3.5.45" + "@vue/shared" "3.5.45" "@vue/devtools-api@^7.7.0": - version "7.7.9" - resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz#999dbea50da6b00cf59a1336f11fdc2b43d9e063" - integrity sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g== + version "7.7.10" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.10.tgz#bfa52dee8918edeea1bfe04cce9f809eeed19862" + integrity sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA== dependencies: - "@vue/devtools-kit" "^7.7.9" + "@vue/devtools-kit" "^7.7.10" -"@vue/devtools-kit@^7.7.9": - version "7.7.9" - resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz#bc218a815616e8987df7ab3e10fc1fb3b8706c58" - integrity sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA== +"@vue/devtools-kit@^7.7.10": + version "7.7.10" + resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz#2f2b6b6eaad9ba96c031fb20c4d59c80d35dfad3" + integrity sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw== dependencies: - "@vue/devtools-shared" "^7.7.9" + "@vue/devtools-shared" "^7.7.10" birpc "^2.3.0" hookable "^5.5.3" mitt "^3.0.1" @@ -643,50 +648,51 @@ speakingurl "^14.0.1" superjson "^2.2.2" -"@vue/devtools-shared@^7.7.9": - version "7.7.9" - resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz#fa4c096b744927081a7dda5fcf05f34b1ae6ca14" - integrity sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA== +"@vue/devtools-shared@^7.7.10": + version "7.7.10" + resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz#ba45001aeb5f7b99a72db2fb824fc04a280aca53" + integrity sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ== dependencies: rfdc "^1.4.1" -"@vue/reactivity@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.26.tgz#59a1edf566dc80133c1c26c93711c877e8602c48" - integrity sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ== +"@vue/reactivity@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.45.tgz#f1abf52d03d352db1608d788551541fd3c680332" + integrity sha512-WqkyubNWpI4OYBOipSrjIssMdJO8K1i9Zts/Wm8Er10/0msOEwCyWWWvZnAI7WW3kQsTong6DJp8Cbb+vd4cPA== dependencies: - "@vue/shared" "3.5.26" + "@vue/shared" "3.5.45" -"@vue/runtime-core@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz#3f2c040bcf8018c03a1ab5adb0d788c13c986f0e" - integrity sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q== +"@vue/runtime-core@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.45.tgz#170664cec5211b4270de3b747db2c8bc5ab39c45" + integrity sha512-bzIO3+FABjpj85uIKSc+Qnj36/sxDNI3tcH9i6w6xKA1k6e0ejv0G0jMCfeJBvK3yGL5XbJ3DMZj+ClqyCHFYw== dependencies: - "@vue/reactivity" "3.5.26" - "@vue/shared" "3.5.26" + "@vue/reactivity" "3.5.45" + "@vue/shared" "3.5.45" -"@vue/runtime-dom@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz#5954848614883948ecc1f631a67b32cc32f81936" - integrity sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ== +"@vue/runtime-dom@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.45.tgz#9a4652ce58c3de3d5f791a63a134abcddbe39a28" + integrity sha512-ZvRTFMDgfqTDYWvJfoVpQTJx1RN5M6l7Pyd6wfdY2NaXzbPQVh9ezeQYFbNPP6bIcRVn2SqSSR25N/jSW69BFw== dependencies: - "@vue/reactivity" "3.5.26" - "@vue/runtime-core" "3.5.26" - "@vue/shared" "3.5.26" + "@vue/reactivity" "3.5.45" + "@vue/runtime-core" "3.5.45" + "@vue/shared" "3.5.45" csstype "^3.2.3" -"@vue/server-renderer@3.5.26": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz#269055497fcc75b3984063f866f17c748b565ef4" - integrity sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA== +"@vue/server-renderer@3.5.45": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.45.tgz#eacee9a2e40166eaaf7e33d3b6709c73d82bd2dc" + integrity sha512-1fNXUHhhsWf4EVL4XixbGBU5mKdiIXJAB82feHRTyPPo6ZUEYWIJuEeUl3mVV6QDBp50HJOX4obLhxYXlrjrzA== dependencies: - "@vue/compiler-ssr" "3.5.26" - "@vue/shared" "3.5.26" + "@vue/compiler-ssr" "3.5.45" + "@vue/runtime-dom" "3.5.45" + "@vue/shared" "3.5.45" -"@vue/shared@3.5.26", "@vue/shared@^3.5.13": - version "3.5.26" - resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.26.tgz#1e02ef2d64aced818cd31d81ce5175711dc90a9f" - integrity sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A== +"@vue/shared@3.5.45", "@vue/shared@^3.5.13": + version "3.5.45" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.45.tgz#7cf2a22b13b2f9d0fd01cd45fb6e7c8a41fa22b8" + integrity sha512-ue9fgHmVNPlP7iW3+cxoBX/Cnlw53dHlVSw0JKGY6z1lObg8pHoj928M2nzG4/yVsMZ3NaRZ2s2z61uC16NDwQ== "@vueuse/core@12.8.2", "@vueuse/core@^12.4.0": version "12.8.2" @@ -720,24 +726,24 @@ vue "^3.5.13" algoliasearch@^5.14.2: - version "5.46.2" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.46.2.tgz#3afba0e53f3293e39cfde9b2ef27c583d44bf2a5" - integrity sha512-qqAXW9QvKf2tTyhpDA4qXv1IfBwD2eduSW6tUEBFIfCeE9gn9HQ9I5+MaKoenRuHrzk5sQoNh1/iof8mY7uD6Q== + version "5.61.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.61.0.tgz#b3f5d1e5e98057cc16bb8ff1ce551cde3978cb41" + integrity sha512-CFtHkGANnIAG6TY5g9s/h+yxZ0SN2/ICMjMY1EWj9gKGiS4LSGFrmMmTA/xxyxOH8AEazFD3YSfRZMh5vIEh2w== dependencies: - "@algolia/abtesting" "1.12.2" - "@algolia/client-abtesting" "5.46.2" - "@algolia/client-analytics" "5.46.2" - "@algolia/client-common" "5.46.2" - "@algolia/client-insights" "5.46.2" - "@algolia/client-personalization" "5.46.2" - "@algolia/client-query-suggestions" "5.46.2" - "@algolia/client-search" "5.46.2" - "@algolia/ingestion" "1.46.2" - "@algolia/monitoring" "1.46.2" - "@algolia/recommend" "5.46.2" - "@algolia/requester-browser-xhr" "5.46.2" - "@algolia/requester-fetch" "5.46.2" - "@algolia/requester-node-http" "5.46.2" + "@algolia/abtesting" "1.27.0" + "@algolia/client-abtesting" "5.61.0" + "@algolia/client-analytics" "5.61.0" + "@algolia/client-common" "5.61.0" + "@algolia/client-insights" "5.61.0" + "@algolia/client-personalization" "5.61.0" + "@algolia/client-query-suggestions" "5.61.0" + "@algolia/client-search" "5.61.0" + "@algolia/ingestion" "1.61.0" + "@algolia/monitoring" "1.61.0" + "@algolia/recommend" "5.61.0" + "@algolia/requester-browser-xhr" "5.61.0" + "@algolia/requester-fetch" "5.61.0" + "@algolia/requester-node-http" "5.61.0" birpc@^2.3.0: version "2.9.0" @@ -765,11 +771,9 @@ comma-separated-tokens@^2.0.0: integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== copy-anything@^4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-4.0.5.tgz#16cabafd1ea4bb327a540b750f2b4df522825aea" - integrity sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA== - dependencies: - is-what "^5.2.0" + version "4.1.5" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-4.1.5.tgz#11866cab9f08ef1f98daac8c468e10d2830294e3" + integrity sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ== csstype@^3.2.3: version "3.2.3" @@ -793,10 +797,10 @@ emoji-regex-xs@^1.0.0: resolved "https://registry.yarnpkg.com/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz#e8af22e5d9dbd7f7f22d280af3d19d2aab5b0724" integrity sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg== -entities@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.0.tgz#2ae4e443f3f17d152d3f5b0f79b932c1e59deb7a" - integrity sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ== +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== esbuild@^0.21.3: version "0.21.5" @@ -845,9 +849,9 @@ fsevents@~2.3.2, fsevents@~2.3.3: integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== hast-util-to-html@^9.0.4: - version "9.0.5" - resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" - integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + version "9.0.6" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.6.tgz#249d71c9d2549b4c4953230e9327e5e6d6a22025" + integrity sha512-1SZUxCN3TtJAE7e2BQN2MBHsoZ1gnmi9GoCP5Di59CSWptIezSAW09yDZyVbanb78V9eCwrKqdQsjtfVTxYHWA== dependencies: "@types/hast" "^3.0.0" "@types/unist" "^3.0.0" @@ -878,11 +882,6 @@ html-void-elements@^3.0.0: resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== -is-what@^5.2.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/is-what/-/is-what-5.5.0.tgz#a3031815757cfe1f03fed990bf6355a2d3f628c4" - integrity sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw== - magic-string@^0.30.21: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" @@ -911,36 +910,36 @@ mdast-util-to-hast@^13.0.0: vfile "^6.0.0" micromark-util-character@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.0.tgz#31320ace16b4644316f6bf057531689c71e2aee1" - integrity sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ== + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== dependencies: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" micromark-util-encode@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz#0921ac7953dc3f1fd281e3d1932decfdb9382ab1" - integrity sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA== + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== micromark-util-sanitize-uri@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz#ec8fbf0258e9e6d8f13d9e4770f9be64342673de" - integrity sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw== + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== dependencies: micromark-util-character "^2.0.0" micromark-util-encode "^2.0.0" micromark-util-symbol "^2.0.0" micromark-util-symbol@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz#12225c8f95edf8b17254e47080ce0862d5db8044" - integrity sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw== + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== micromark-util-types@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.0.tgz#63b4b7ffeb35d3ecf50d1ca20e68fc7caa36d95e" - integrity sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w== + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.3.tgz#e7b04fa58589199c7ef58b37cd696fcb2bce44e7" + integrity sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg== minisearch@^7.1.1: version "7.2.0" @@ -952,10 +951,10 @@ mitt@^3.0.1: resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== -nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== +nanoid@^3.3.20: + version "3.3.20" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.20.tgz#dfd4444d85aa10111ef66ba85ed72e08ba0855d8" + integrity sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ== oniguruma-to-es@^3.1.0: version "3.1.1" @@ -976,24 +975,24 @@ picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -postcss@^8.4.43, postcss@^8.5.6: - version "8.5.25" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.25.tgz#5012a598eaaa897f21bbe8553be3cb7bd2bd78cb" - integrity sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw== +postcss@^8.4.43, postcss@^8.5.32: + version "8.5.32" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.32.tgz#fbccef8388a0c77508c713bfd37728d646595803" + integrity sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g== dependencies: - nanoid "^3.3.16" + nanoid "^3.3.20" picocolors "^1.1.1" - source-map-js "^1.2.1" + source-map-js "^1.2.2" preact@^10.0.0: - version "10.21.0" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.21.0.tgz#5b0335c873a1724deb66e517830db4fd310c24f6" - integrity sha512-aQAIxtzWEwH8ou+OovWVSVNlFImL7xUCwJX3YMqA3U8iKCNC34999fFOnWjYNsylgfPgMexpbk7WYOLtKr/mxg== + version "10.29.8" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.8.tgz#fc85b82dc2474e245b1430b51781d417361f5fc3" + integrity sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q== property-information@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" - integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== + version "7.2.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.2.0.tgz#0809b34264e995c0bfcd3227028a1e35210af80a" + integrity sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg== regex-recursion@^6.0.2: version "6.0.2" @@ -1020,37 +1019,38 @@ rfdc@^1.4.1: integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== rollup@^4.20.0: - version "4.59.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.59.0.tgz#cf74edac17c1486f562d728a4d923a694abdf06f" - integrity sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== + version "4.68.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.68.0.tgz#3dad30d9b18a9bced13222183e089e2e63ee05c0" + integrity sha512-BIHWnc1Qjdy+WLfOdcSd67sxO4iW6pTnhdZosg+7ywSaiMWxxzKldkkNpUJqeXYAiJzyB14P2cIjqC6tlb6xzw== dependencies: - "@types/estree" "1.0.8" + "@types/estree" "^1.0.10" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.59.0" - "@rollup/rollup-android-arm64" "4.59.0" - "@rollup/rollup-darwin-arm64" "4.59.0" - "@rollup/rollup-darwin-x64" "4.59.0" - "@rollup/rollup-freebsd-arm64" "4.59.0" - "@rollup/rollup-freebsd-x64" "4.59.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.59.0" - "@rollup/rollup-linux-arm-musleabihf" "4.59.0" - "@rollup/rollup-linux-arm64-gnu" "4.59.0" - "@rollup/rollup-linux-arm64-musl" "4.59.0" - "@rollup/rollup-linux-loong64-gnu" "4.59.0" - "@rollup/rollup-linux-loong64-musl" "4.59.0" - "@rollup/rollup-linux-ppc64-gnu" "4.59.0" - "@rollup/rollup-linux-ppc64-musl" "4.59.0" - "@rollup/rollup-linux-riscv64-gnu" "4.59.0" - "@rollup/rollup-linux-riscv64-musl" "4.59.0" - "@rollup/rollup-linux-s390x-gnu" "4.59.0" - "@rollup/rollup-linux-x64-gnu" "4.59.0" - "@rollup/rollup-linux-x64-musl" "4.59.0" - "@rollup/rollup-openbsd-x64" "4.59.0" - "@rollup/rollup-openharmony-arm64" "4.59.0" - "@rollup/rollup-win32-arm64-msvc" "4.59.0" - "@rollup/rollup-win32-ia32-msvc" "4.59.0" - "@rollup/rollup-win32-x64-gnu" "4.59.0" - "@rollup/rollup-win32-x64-msvc" "4.59.0" + "@rollup/rollup-android-arm-eabi" "4.68.0" + "@rollup/rollup-android-arm64" "4.68.0" + "@rollup/rollup-android-x64" "4.68.0" + "@rollup/rollup-darwin-arm64" "4.68.0" + "@rollup/rollup-darwin-x64" "4.68.0" + "@rollup/rollup-freebsd-arm64" "4.68.0" + "@rollup/rollup-freebsd-x64" "4.68.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.68.0" + "@rollup/rollup-linux-arm-musleabihf" "4.68.0" + "@rollup/rollup-linux-arm64-gnu" "4.68.0" + "@rollup/rollup-linux-arm64-musl" "4.68.0" + "@rollup/rollup-linux-loong64-gnu" "4.68.0" + "@rollup/rollup-linux-loong64-musl" "4.68.0" + "@rollup/rollup-linux-ppc64-gnu" "4.68.0" + "@rollup/rollup-linux-ppc64-musl" "4.68.0" + "@rollup/rollup-linux-riscv64-gnu" "4.68.0" + "@rollup/rollup-linux-riscv64-musl" "4.68.0" + "@rollup/rollup-linux-s390x-gnu" "4.68.0" + "@rollup/rollup-linux-x64-gnu" "4.68.0" + "@rollup/rollup-linux-x64-musl" "4.68.0" + "@rollup/rollup-openbsd-x64" "4.68.0" + "@rollup/rollup-openharmony-arm64" "4.68.0" + "@rollup/rollup-win32-arm64-msvc" "4.68.0" + "@rollup/rollup-win32-ia32-msvc" "4.68.0" + "@rollup/rollup-win32-x64-gnu" "4.68.0" + "@rollup/rollup-win32-x64-msvc" "4.68.0" fsevents "~2.3.2" shiki@^2.1.0: @@ -1067,10 +1067,10 @@ shiki@^2.1.0: "@shikijs/vscode-textmate" "^10.0.2" "@types/hast" "^3.0.4" -source-map-js@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" - integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +source-map-js@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab" + integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw== space-separated-tokens@^2.0.0: version "2.0.2" @@ -1098,9 +1098,9 @@ superjson@^2.2.2: copy-anything "^4" tabbable@^6.4.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581" - integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg== + version "6.5.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.5.1.tgz#cfca6f3f397db9edbe08ad35915e0d7807050a18" + integrity sha512-dMCuwDUY6Noz3zVKLefBC4wq+aJV20yc7vWmM7uTNGwtQSFF5kX7OUIUsxuVajz/bdt5Xqdah3/0NLjth+cM5A== trim-lines@^3.0.0: version "3.0.1" @@ -1108,9 +1108,9 @@ trim-lines@^3.0.0: integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== unist-util-is@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" - integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9" + integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g== dependencies: "@types/unist" "^3.0.0" @@ -1128,27 +1128,27 @@ unist-util-stringify-position@^4.0.0: dependencies: "@types/unist" "^3.0.0" -unist-util-visit-parents@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" - integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== +unist-util-visit-parents@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.1.0.tgz#eff74226782b378957731bcf62cf5e9d8a080606" + integrity sha512-vKXxz6O38sKDj1mtx/k2cWvkkFBuYGx6uGrlzBkVeWCRF/F8sdI0j3Wbkz/5rVNRRuVc8UrgRdbCxNhmYXgP3w== dependencies: "@types/unist" "^3.0.0" unist-util-is "^6.0.0" unist-util-visit@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" - integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + version "5.2.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.2.0.tgz#914256bc5e1801e280c12970fad4328352645974" + integrity sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ== dependencies: "@types/unist" "^3.0.0" unist-util-is "^6.0.0" - unist-util-visit-parents "^6.0.0" + unist-util-visit-parents "^6.1.0" vfile-message@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" - integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + version "4.0.4" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.4.tgz#24dd2080867c336e121a2b7446c247d935022565" + integrity sha512-66ycRswIkNmHH9PHku7ADtv5RVwgIrOcuv3J8Svz6h4qgGRrKVa+9/neuKyJZUXyOpYwLTvS+whZ4R2urHHgQg== dependencies: "@types/unist" "^3.0.0" unist-util-stringify-position "^4.0.0" @@ -1197,15 +1197,15 @@ vitepress@^1.6.4: vue "^3.5.13" vue@^3.5.13: - version "3.5.26" - resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.26.tgz#03a0b17311e0e593d34b9358fa249b85e3a6d9fb" - integrity sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA== + version "3.5.45" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.45.tgz#a86c2ac53d76c72f919043d4c70ccaa5d8f06a80" + integrity sha512-3cvsib8m9c0vB24+MDKsgcW4+Da4xaBl9Z/5Tbm2pmSIsRPxcil4cdXjXlVsmJ2g64XJo5h+IRd7rsdGT9pZaw== dependencies: - "@vue/compiler-dom" "3.5.26" - "@vue/compiler-sfc" "3.5.26" - "@vue/runtime-dom" "3.5.26" - "@vue/server-renderer" "3.5.26" - "@vue/shared" "3.5.26" + "@vue/compiler-dom" "3.5.45" + "@vue/compiler-sfc" "3.5.45" + "@vue/runtime-dom" "3.5.45" + "@vue/server-renderer" "3.5.45" + "@vue/shared" "3.5.45" zwitch@^2.0.4: version "2.0.4" diff --git a/frontend/biome.json b/frontend/biome.json index e84969d2b..ff3bbd38a 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/frontend/check-locales.cjs b/frontend/check-locales.cjs index 251b19c4b..1b02ce8ec 100644 --- a/frontend/check-locales.cjs +++ b/frontend/check-locales.cjs @@ -23,6 +23,7 @@ const allLocales = [ ["cs", "cs-CZ"], ["vi", "vi-VN"], ["zh", "zh-CN"], + ["zh-tw", "zh-TW"], ["ko", "ko-KR"], ["bg", "bg-BG"], ["id", "id-ID"], diff --git a/frontend/package.json b/frontend/package.json index e2cc7ee64..22e0dd6df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,11 +16,11 @@ "test": "vitest" }, "dependencies": { - "@tabler/core": "^1.5.1", - "@tabler/icons-react": "^3.46.0", - "@tanstack/react-query": "^5.102.8", - "@tanstack/react-table": "^9.2.4", - "@uiw/react-textarea-code-editor": "^3.1.1", + "@tabler/core": "^1.7.4", + "@tabler/icons-react": "^3.51.0", + "@tanstack/react-query": "^5.107.1", + "@tanstack/react-table": "^9.3.1", + "@uiw/react-textarea-code-editor": "^3.1.3", "classnames": "^2.5.1", "country-flag-icons": "^1.6.20", "date-fns": "^4.4.0", @@ -28,40 +28,40 @@ "formik": "^2.4.9", "generate-password-browser": "^1.1.0", "humps": "^2.0.1", - "query-string": "^9.5.1", - "react": "^19.3.0", + "query-string": "^10.0.0", + "react": "^19.3.1", "react-bootstrap": "^2.10.10", - "react-dom": "^19.3.0", - "react-intl": "^10.2.0", + "react-dom": "^19.3.1", + "react-intl": "^12.1.7", "react-markdown": "^10.1.0", "react-qr-code": "^2.2.0", - "react-router-dom": "^7.18.4", + "react-router-dom": "^7.18.5", "react-select": "^5.10.2", - "react-toastify": "^11.1.0", + "react-toastify": "^11.2.0", "rooks": "^9.9.0" }, "devDependencies": { - "@biomejs/biome": "^2.5.10", - "@formatjs/cli": "^6.16.30", - "@tanstack/react-query-devtools": "^5.103.1", + "@biomejs/biome": "^2.6.6", + "@formatjs/cli": "^6.18.1", + "@tanstack/react-query-devtools": "^5.107.1", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", - "@types/country-flag-icons": "^1.2.2", + "@types/country-flag-icons": "^1.6.20", "@types/humps": "^2.0.6", - "@types/node": "^26.6.2", + "@types/node": "^26.6.6", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/react-table": "^7.7.20", - "@vitejs/plugin-react": "^6.1.1", - "happy-dom": "^20.14.5", - "postcss": "^8.5.28", + "@vitejs/plugin-react": "^6.2.1", + "happy-dom": "^21.0.4", + "postcss": "^8.5.32", "postcss-simple-vars": "^7.0.1", - "sass": "^1.104.1", + "sass": "^1.108.1", "tmp": "^0.2.7", "typescript": "7.0.2", - "vite": "^8.3.0", - "vite-plugin-checker": "^0.14.5", - "vitest": "^4.1.11" + "vite": "^8.3.6", + "vite-plugin-checker": "^1.0.0", + "vitest": "^5.1.0" } } diff --git a/frontend/src/App.css b/frontend/src/App.css index ad335caea..894f70cc9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -115,4 +115,31 @@ label.row { :host, :root { margin-left: 0; } +} + +/* Keep Tabler 1.5's behaviour on Tabler 1.6/1.7. App.css loads before the + dynamically imported Tabler CSS, hence the doubled :root selectors. */ + +/* 1.6 dropped the reserved scrollbar gutter, so the layout shifted 7px between short and long pages */ +html { + scrollbar-gutter: stable; +} +@supports not (scrollbar-gutter: stable) { + html { + overflow-y: scroll; + } +} + +/* 1.6+ mixes the focus ring 50% with the background (75% dark, about 2.1:1 on white); restore full strength */ +:root:root { + --tblr-focus-ring-opacity: 1; + --tblr-focus-ring-opacity-dark: 1; +} + +/* Coloured primary buttons (btn-primary + bg-) got a blue primary border in 1.7 */ +.btn-primary[class*=" bg-"] { + --tblr-btn-border-color: transparent; + --tblr-btn-hover-border-color: transparent; + --tblr-btn-active-border-color: transparent; + --tblr-btn-box-shadow: var(--tblr-shadow-input); } \ No newline at end of file diff --git a/frontend/src/api/backend/base.ts b/frontend/src/api/backend/base.ts index d56f120c7..e91376d44 100644 --- a/frontend/src/api/backend/base.ts +++ b/frontend/src/api/backend/base.ts @@ -46,7 +46,7 @@ function buildBody(data?: Record): string | undefined { } } -async function processResponse(response: Response) { +async function processResponse(response: Response, preserveResponseKeys = false) { const payload = await response.json(); if (!response.ok) { if (response.status === 401) { @@ -59,7 +59,7 @@ async function processResponse(response: Response) { typeof payload.error.messageI18n !== "undefined" ? payload.error.messageI18n : payload.error.message, ); } - return camelizeKeys(payload) as any; + return (preserveResponseKeys ? payload : camelizeKeys(payload)) as any; } interface GetArgs { @@ -97,9 +97,10 @@ interface PostArgs { params?: queryString.StringifiableRecord; data?: any; noAuth?: boolean; + preserveResponseKeys?: boolean; } -export async function post({ url, params, data, noAuth }: PostArgs, abortController?: AbortController) { +export async function post({ url, params, data, noAuth, preserveResponseKeys }: PostArgs, abortController?: AbortController) { const apiUrl = buildUrl({ url, params }); const method = "POST"; @@ -126,7 +127,7 @@ export async function post({ url, params, data, noAuth }: PostArgs, abortControl const signal = abortController?.signal; const response = await fetch(apiUrl, { method, headers, body, signal }); - return processResponse(response); + return processResponse(response, preserveResponseKeys); } interface PutArgs { diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 96c1b8a48..347c62744 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -16,6 +16,11 @@ export interface UserPermissions { streams: string; accessLists: string; certificates: string; + meta?: { + proxyHostIds?: number[]; + proxy_host_ids?: number[]; + [key: string]: any; + }; } export interface User { diff --git a/frontend/src/api/backend/testHttpCertificate.test.ts b/frontend/src/api/backend/testHttpCertificate.test.ts new file mode 100644 index 000000000..ecbe1dbed --- /dev/null +++ b/frontend/src/api/backend/testHttpCertificate.test.ts @@ -0,0 +1,92 @@ +import AuthStore from "src/modules/AuthStore"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "./base"; +import { testHttpCertificate } from "./testHttpCertificate"; + +const fetchMock = vi.fn(); +const reply = (payload: unknown, status = 200) => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status })); +}; +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + fetchMock.mockReset(); +}); +describe("certificate HTTP result keys", () => { + it.each([ + "test-domain.example", + "test--domain.example", + "xn--bcher-kva.example", + "one.two-three.example", + "_acme-challenge.example", + "Test-Domain.example", + "plain.example", + ])("preserves the exact response key %s", async (domain) => { + const result = { [domain]: "wrong-data" }; + reply(result); + expect(await testHttpCertificate([domain])).toEqual(result); + }); + it("keeps colliding spellings and their distinct statuses", async () => { + const result = { "test-domain.example": "ok", "test--domain.example": "no-host" }; + reply(result); + expect(await testHttpCertificate(Object.keys(result))).toEqual(result); + }); + it("sends unchanged domains with the normal request headers", async () => { + const domains = ["test-domain.example", "test--domain.example"]; + reply({}); + await testHttpCertificate(domains); + expect(fetchMock).toHaveBeenCalledWith("/api/nginx/certificates/test-http", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domains }), + signal: undefined, + }); + }); + it("preserves an empty result", async () => { + reply({}); + expect(await testHttpCertificate([])).toEqual({}); + }); + it.each([ + [400, { message: "invalid domain" }, "invalid domain"], + [500, { message: "fallback", messageI18n: "certificate-error" }, "certificate-error"], + ])("preserves error handling for status %s", async (status, error, message) => { + reply({ error }, status); + await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow(message); + }); + it("still clears authentication on 401", async () => { + const clear = vi.spyOn(AuthStore, "clear"); + const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {}); + reply({ error: { message: "unauthorized" } }, 401); + await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow("unauthorized"); + expect(clear).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledOnce(); + }); + it.each([ + ["get", api.get], + ["post", api.post], + ["put", api.put], + ["del", api.del], + ] as const)("keeps default %s response normalization", async (_name, method) => { + reply({ domain_names: ["test-domain.example"], nested_value: { created_on: "today" } }); + expect(await method({ url: "/control" })).toEqual({ + domainNames: ["test-domain.example"], + nestedValue: { createdOn: "today" }, + }); + }); + it("preserves POST abort forwarding and request normalization", async () => { + const controller = new AbortController(); + reply({ domain_names: [] }); + await api.post({ url: "/control", data: { domainNames: ["a-b.example"] } }, controller); + expect(fetchMock).toHaveBeenCalledWith( + "/api/control", + expect.objectContaining({ + body: JSON.stringify({ domain_names: ["a-b.example"] }), + signal: controller.signal, + }), + ); + }); +}); diff --git a/frontend/src/api/backend/testHttpCertificate.ts b/frontend/src/api/backend/testHttpCertificate.ts index 23dfa4b7d..9687d55af 100644 --- a/frontend/src/api/backend/testHttpCertificate.ts +++ b/frontend/src/api/backend/testHttpCertificate.ts @@ -3,6 +3,7 @@ import * as api from "./base"; export async function testHttpCertificate(domains: string[]): Promise> { return await api.post({ url: "/nginx/certificates/test-http", + preserveResponseKeys: true, data: { domains, }, diff --git a/frontend/src/components/Form/BasicAuthFields.tsx b/frontend/src/components/Form/BasicAuthFields.tsx index d8d33d159..0d5548490 100644 --- a/frontend/src/components/Form/BasicAuthFields.tsx +++ b/frontend/src/components/Form/BasicAuthFields.tsx @@ -38,8 +38,7 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) { }; const setFormField = (newValues: AccessListItem[]) => { - const filtered = newValues.filter((v: AccessListItem) => v?.username?.trim() !== ""); - setFieldValue(name, filtered); + setFieldValue(name, newValues); }; return ( diff --git a/frontend/src/components/Form/FormModal.drafts.test.tsx b/frontend/src/components/Form/FormModal.drafts.test.tsx new file mode 100644 index 000000000..7e1e11633 --- /dev/null +++ b/frontend/src/components/Form/FormModal.drafts.test.tsx @@ -0,0 +1,63 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { Form } from "formik"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BasicAuthFields } from "./BasicAuthFields"; +import { FormModal } from "./FormModal"; +import { LocationsFields } from "./LocationsFields"; + +vi.mock("./AccessField", () => ({ AccessField: () => null })); + +const renderDraft = (initialValues: Record, children: ReactNode) => { + const onHide = vi.fn(); + render( + + {({ onHide: hide }) => ( +
+ {children} + +
+ )} +
, + ); + return onHide; +}; + +describe("FormModal local drafts", () => { + beforeEach(() => { + vi.stubGlobal( + "confirm", + vi.fn(() => false), + ); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("protects a location draft without a path", () => { + const onHide = renderDraft({ locations: [] }, ); + fireEvent.click(screen.getByRole("button", { name: "Add Location" })); + fireEvent.change(screen.getByPlaceholderText("eg: 10.0.0.1/path/"), { target: { value: "example.com" } }); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(window.confirm).toHaveBeenCalledOnce(); + expect(onHide).not.toHaveBeenCalled(); + }); + + it("protects a basic auth password draft without a username", () => { + const onHide = renderDraft({ items: [] }, ); + fireEvent.change(document.querySelector('input[type="password"]') as Element, { + target: { value: "secret" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(window.confirm).toHaveBeenCalledOnce(); + expect(onHide).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Form/FormModal.test.tsx b/frontend/src/components/Form/FormModal.test.tsx new file mode 100644 index 000000000..de4080596 --- /dev/null +++ b/frontend/src/components/Form/FormModal.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { Field, Form } from "formik"; +import Modal from "react-bootstrap/Modal"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FormModal } from "./FormModal"; + +const renderModal = (onHide = vi.fn(), preventHide = false) => { + render( + + {({ onHide: hide }) => ( +
+ + + + + + )} +
, + ); + return onHide; +}; + +describe("FormModal", () => { + beforeEach(() => { + vi.stubGlobal("confirm", vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("dismisses a pristine form without confirmation", () => { + const onHide = renderModal(); + const confirm = vi.spyOn(window, "confirm"); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(confirm).not.toHaveBeenCalled(); + expect(onHide).toHaveBeenCalledOnce(); + }); + + it("keeps a dirty form open when dismissal is cancelled", () => { + const onHide = renderModal(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } }); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + fireEvent.click(document.querySelector(".modal") as Element); + fireEvent.keyDown(document, { key: "Escape", keyCode: 27 }); + + expect(confirm).toHaveBeenCalledTimes(4); + expect(onHide).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Name")).toHaveValue("example.com"); + }); + + it("dismisses a dirty form after confirmation", () => { + const onHide = renderModal(); + vi.spyOn(window, "confirm").mockReturnValue(true); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } }); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onHide).toHaveBeenCalledOnce(); + }); + + it("does not offer to discard changes while submission is in progress", () => { + const onHide = renderModal(vi.fn(), true); + const confirm = vi.spyOn(window, "confirm"); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(confirm).not.toHaveBeenCalled(); + expect(onHide).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Name")).toHaveValue("example.com"); + }); +}); diff --git a/frontend/src/components/Form/FormModal.tsx b/frontend/src/components/Form/FormModal.tsx new file mode 100644 index 000000000..d9698521d --- /dev/null +++ b/frontend/src/components/Form/FormModal.tsx @@ -0,0 +1,55 @@ +import { Formik, type FormikConfig, type FormikProps, type FormikValues } from "formik"; +import { type ReactNode, useEffect } from "react"; +import Modal from "react-bootstrap/Modal"; +import { intl } from "src/locale"; + +const confirmDiscardChanges = (dirty: boolean) => + !dirty || + window.confirm( + intl.formatMessage({ + id: "form.unsaved-changes", + defaultMessage: "You have unsaved changes. Are you sure you want to discard them?", + }), + ); + +const FormDirtyState = ({ dirty, onChange }: { dirty: boolean; onChange: (dirty: boolean) => void }) => { + useEffect(() => { + onChange(dirty); + return () => onChange(false); + }, [dirty, onChange]); + + return null; +}; + +interface FormModalProps extends Omit, "children"> { + show: boolean; + onHide: () => void; + preventHide?: boolean; + children: (props: FormikProps & { onHide: () => void }) => ReactNode; +} + +const FormModal = ({ + show, + onHide, + preventHide = false, + children, + ...formikProps +}: FormModalProps) => ( + + {(formikProps) => { + const handleHide = () => { + if (!preventHide && confirmDiscardChanges(formikProps.dirty)) { + onHide(); + } + }; + + return ( + + {children({ ...formikProps, onHide: handleHide })} + + ); + }} + +); + +export { confirmDiscardChanges, FormDirtyState, FormModal }; diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index a0f64c239..8cd826702 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -30,8 +30,9 @@ interface Row { interface Props { initialValues: ProxyLocation[]; name?: string; + allowAdvanced?: boolean; } -export function LocationsFields({ initialValues, name = "locations" }: Props) { +export function LocationsFields({ initialValues, name = "locations", allowAdvanced = true }: Props) { const [rows, setRows] = useState(() => (initialValues || []).map((value, id) => ({ id, value }))); const { setFieldValue } = useFormikContext(); const [expanded, setExpanded] = useState([]); @@ -88,8 +89,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { }; const setFormField = (newRows: Row[]) => { - const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== ""); - setFieldValue(name, filtered); + setFieldValue( + name, + newRows.map((r: Row) => r.value), + ); }; const forwardSummary = (item: ProxyLocation) => { @@ -215,7 +218,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { {isOpen && (
-
+
Location
-
- -
+ {allowAdvanced && ( +
+ +
+ )}
-
+
-
+
-
+
-
+ + + +
+ -
- - -
- - - )} - - + +
+
+ + )} + ); }); diff --git a/frontend/src/modals/HelpModal.tsx b/frontend/src/modals/HelpModal.tsx index f05816142..1d395fc11 100644 --- a/frontend/src/modals/HelpModal.tsx +++ b/frontend/src/modals/HelpModal.tsx @@ -18,7 +18,7 @@ const showHelpModal = (section: string, color?: string) => { const HelpModal = EasyModal.create(({ section, color, visible, remove }: Props) => { const [markdownText, setMarkdownText] = useState(""); - const lang = getLocale(true); + const lang = getLocale(); useEffect(() => { try { diff --git a/frontend/src/modals/PermissionsModal.tsx b/frontend/src/modals/PermissionsModal.tsx index d363de9ed..8ac607cca 100644 --- a/frontend/src/modals/PermissionsModal.tsx +++ b/frontend/src/modals/PermissionsModal.tsx @@ -1,13 +1,14 @@ import { useQueryClient } from "@tanstack/react-query"; import cn from "classnames"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { Field, Form, Formik } from "formik"; +import { Field, Form } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { setPermissions } from "src/api/backend"; import { Button, Loading } from "src/components"; -import { useUser } from "src/hooks"; +import { FormModal } from "src/components/Form"; +import { useProxyHosts, useUser } from "src/hooks"; import { T } from "src/locale"; import styles from "./PermissionsModal.module.css"; @@ -22,6 +23,7 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { const queryClient = useQueryClient(); const [errorMsg, setErrorMsg] = useState(null); const { data, isLoading, error } = useUser(id); + const { data: proxyHosts } = useProxyHosts(); const [isSubmitting, setIsSubmitting] = useState(false); const onSubmit = async (values: any, { setSubmitting }: any) => { @@ -29,7 +31,13 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { setIsSubmitting(true); setErrorMsg(null); try { - await setPermissions(id, values); + // Proxy host grants only apply to "user" visibility with proxy hosts shown. Don't + // submit stale ones; the backend clears them too. + const grantsApply = values.visibility === "user" && values.proxyHosts !== "hidden"; + await setPermissions(id, { + ...values, + meta: { ...values.meta, proxy_host_ids: grantsApply ? values.meta?.proxy_host_ids || [] : [] }, + }); remove(); queryClient.invalidateQueries({ queryKey: ["users"] }); queryClient.invalidateQueries({ queryKey: ["user"] }); @@ -133,29 +141,42 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { const isAdmin = data?.roles.indexOf("admin") !== -1; return ( - - {!isLoading && error && ( - - {error?.message || "Unknown error"} - + <> + {(isLoading || (error && !data)) && ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + )} - {isLoading && } {!isLoading && data && ( - - {() => ( + {({ values, setFieldValue, onHide }: any) => (
@@ -219,6 +240,71 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { {({ field, form }: any) => getPermissionButtons(field, form)}
+ {values.visibility === "user" && values.proxyHosts !== "hidden" && ( +
+ +
+ +
+
+ {proxyHosts && proxyHosts.length > 0 ? ( + proxyHosts.map((host) => { + const selectedIds: number[] = + values.meta?.proxy_host_ids || []; + const isChecked = selectedIds.includes(host.id); + return ( +
+ { + const newIds = e.target.checked + ? [...selectedIds, host.id] + : selectedIds.filter( + (hId) => hId !== host.id, + ); + setFieldValue( + "meta.proxy_host_ids", + newIds, + ); + }} + /> + +
+ ); + }) + ) : ( +
+ +
+ )} +
+
+ )}
@@ -165,12 +187,12 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
-
+
{({ field, form }: any) => (
-
+
{({ field, form }: any) => (
-
-
+
{({ field, form }: any) => (
-
- +
{ />
-
- -
+ {isAdmin && ( +
+ +
+ )}
- @@ -368,9 +401,9 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { )} - + )} - + ); }); diff --git a/frontend/src/modals/RedirectionHostModal.tsx b/frontend/src/modals/RedirectionHostModal.tsx index 9a0d95ce8..d83a81bcf 100644 --- a/frontend/src/modals/RedirectionHostModal.tsx +++ b/frontend/src/modals/RedirectionHostModal.tsx @@ -1,7 +1,7 @@ import { IconSettings } from "@tabler/icons-react"; import cn from "classnames"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { Field, Form, Formik } from "formik"; +import { Field, Form } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; @@ -13,7 +13,8 @@ import { SSLCertificateField, SSLOptionsFields, } from "src/components"; -import { useRedirectionHost, useSetRedirectionHost } from "src/hooks"; +import { FormModal } from "src/components/Form"; +import { useRedirectionHost, useSetRedirectionHost, useUser } from "src/hooks"; import { T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; @@ -26,11 +27,14 @@ interface Props extends InnerModalProps { id: number | "new"; } const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) => { + const { data: currentUser, isLoading: userIsLoading, error: userError } = useUser("me"); const { data, isLoading, error } = useRedirectionHost(id); const { mutate: setRedirectionHost } = useSetRedirectionHost(); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isAdmin = currentUser?.roles?.includes("admin"); + const onSubmit = async (values: any, { setSubmitting }: any) => { if (isSubmitting) return; setIsSubmitting(true); @@ -41,6 +45,11 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = ...values, }; + if (!isAdmin) { + delete payload.advanced_config; + delete payload.advancedConfig; + } + setRedirectionHost(payload, { onError: (err: any) => setErrorMsg(), onSuccess: () => { @@ -55,15 +64,22 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = }; return ( - - {!isLoading && error && ( - - {error?.message || "Unknown error"} - + <> + {(isLoading || userIsLoading || ((error || userError) && (!data || !currentUser))) && ( + + {!isLoading && (error || userError) && ( + + {error?.message || userError?.message || "Unknown error"} + + )} + {(isLoading || userIsLoading) && } + )} - {isLoading && } - {!isLoading && data && ( - - {() => ( + {({ onHide }) => (
@@ -126,19 +142,21 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = -
  • - - - -
  • + {isAdmin && ( +
  • + + + +
  • + )}
    @@ -318,15 +336,17 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = />
    -
    - -
    + {isAdmin && ( +
    + +
    + )}
    - - - - - )} - - + + {({ onHide }) => ( +
    + + + + + + + setError(null)} dismissible> + {error} + +
    + + {({ field, form }: any) => ( + <> +

    + + { + e.preventDefault(); + form.setFieldValue( + field.name, + generate({ + length: 12, + numbers: true, + }), + ); + setShowPassword(true); + }} + > + + {" "} + —{" "} + { + e.preventDefault(); + setShowPassword(!showPassword); + }} + > + + + +

    +
    + + + {form.errors.new ? ( +
    + {form.errors.new && form.touched.new ? form.errors.new : null} +
    + ) : null} +
    + + )} +
    +
    +
    + + + + +
    + )} +
    ); }); diff --git a/frontend/src/modals/StreamModal.tsx b/frontend/src/modals/StreamModal.tsx index b87a259c4..417ae3103 100644 --- a/frontend/src/modals/StreamModal.tsx +++ b/frontend/src/modals/StreamModal.tsx @@ -1,9 +1,10 @@ import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { Field, Form, Formik } from "formik"; +import { Field, Form } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components"; +import { FormModal } from "src/components/Form"; import { useSetStream, useStream } from "src/hooks"; import { intl, T } from "src/locale"; import { validateNumber, validateString } from "src/modules/Validations"; @@ -46,15 +47,22 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => { }; return ( - - {!isLoading && error && ( - - {error?.message || "Unknown error"} - + <> + {(isLoading || (error && !data)) && ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + )} - {isLoading && } {!isLoading && data && ( - { } onSubmit={onSubmit} > - {({ setFieldValue }: any) => ( + {({ onHide, setFieldValue }: any) => (
    @@ -302,7 +310,7 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => {
    -