diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml index 1b6427f1f9..0912649889 100644 --- a/.github/workflows/dependency-updates.yml +++ b/.github/workflows/dependency-updates.yml @@ -176,9 +176,6 @@ jobs: - name: nginx repo: nginx/nginx pull: 1333 - - name: nginx - repo: nginx/nginx - pull: 1430 - name: fancyindex repo: aperezdc/ngx-fancyindex pull: 176 diff --git a/.github/workflows/lint-and-format.yml b/.github/workflows/lint-and-format.yml index f23639a121..e2abac9577 100644 --- a/.github/workflows/lint-and-format.yml +++ b/.github/workflows/lint-and-format.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-slim steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: lts/* - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index 5bf5933ebd..3a09f7ad78 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -22,4 +22,4 @@ jobs: # These animated SVGs embed raster frames as base64. Random binary # runs trip the dictionary, but there is no prose to spellcheck. skip: pnpm-lock.yaml,./frontend/translations,./frontend/public/images/crowdsec-logo-animated.svg,./docs/animated-logo.svg - ignore_words_list: afterAll,alog,clen + ignore_words_list: afterAll,alog,clen,hasTable diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9d1173cc..97eeaf0d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to the NPMplus Security Fork are documented here. The fork u ## Unreleased +- Reconcile upstream through October 16: migrate custom locations and their ordered access lists into relational storage, adopt host hard-deletion and mTLS auth-request controls, and update navigation and translations. Keep the fork's session revocation, replay protection, ACL rollback, bounded fetches, setup gate, certificate pipeline, and reviewed dependency versions. Adapt Anubis coverage and ACL recovery to the new relations, with a populated-database upgrade and snapshot regression test. Template version 23 regenerates existing nginx configurations. Database rollback requires restoring the matching pre-upgrade snapshot. + - Refresh nginx to 1.31.7 with matching OpenResty patches and njs 1.0.2. Update the Caddy, CrowdSec, and Anubis security builds to Go 1.26.9 and patched networking dependencies; update CrowdSec's OpenAPI parser and Caddy to 2.11.7. Override vulnerable proxy-addr and source-map-js transitive dependencies. Reassess the pinned pip vendor inventory and scope the remaining packaging-tool exceptions to exact package versions until October 23, preserving runtime Certbot DNS-plugin installation. - Installer v1.69 adds the admin-only **Disable CRS observation** action under CrowdSec → WAF, the mirror of the v1.66 enable button: it removes the observation policy, restores the default acquisition list, strips the bouncer marker, and leaves the installed CRS rules inert while high-confidence blocking keeps running. Both verbs now write a persistent pre-change snapshot to `/var/lib/npmplus/crs-control/snapshot/` — kept after success and after rollback failure as the known-good instance for manual recovery — and the host helper gained a `DISABLE` socket verb behind the same audit, cooldown, and transactional rollback as enable. Uninstall removes the snapshot with the rest of the helper state. diff --git a/FORK.md b/FORK.md index 2a00a2f760..42a458d3d8 100644 --- a/FORK.md +++ b/FORK.md @@ -61,6 +61,34 @@ and a fake GitHub CLI; no real branch or PR is created by the tests. ## Reviewing an upstream update +The October 16 reconciliation incorporates upstream `5ba8fe1c` while retaining +the fork's reviewed dependency versions (including Objection 3 during the new +major release's quarantine), database-backed session/MFA replay guards, and +certificate implementation. Custom locations now use `npmplus_location` and +ordered ACL relations. Fork reporting and ACL rollback must fetch those relations; +reading the legacy `proxy_host.locations` JSON column loses the new data. + +The hard-delete and location migrations cannot migrate down. Roll back with the +safe updater's matching database/files snapshot and image, rather than only +switching image tags. `backend/test/upstream-location-upgrade.test.js` reconstructs +a populated pre-migration fork database, verifies ordered ACLs and inherited +Anubis coverage after upgrade, and checks the pre-upgrade SQLite snapshot. Its +coverage assertion was first observed failing on the unadapted query. + +The fork's fresh-session helper remains asynchronous: MFA enrollment and backup +code regeneration must await it before setting the replacement cookie. The HTTP +regression reproduced enrollment's 500 response before restoring both awaits. +ACL form validation continues to accept explicit `deny all` policies; the browser +regression first timed out waiting for a save request under upstream's empty-policy +check, then verified the exact deny-all payload after the correction. + +Certificate renewal and downloads remain under the owner's existing restriction: +do not import upstream's certificate restructuring without deliberate replacement +pins. The UI continues to offer downloads only for Certbot certificates. Custom +auth-request upstreams retain the fork's URL-only validation contract; upstream's +named-upstream extension needs a separate privilege review. These are deliberate +integration boundaries, not conflicts to erase in a later automated sync. + From a clean checkout, fetch and inspect before changing the working branch: ```bash diff --git a/backend/.smoke/access-list-ui.mjs b/backend/.smoke/access-list-ui.mjs index 80bff0da7b..5b0c6b5730 100644 --- a/backend/.smoke/access-list-ui.mjs +++ b/backend/.smoke/access-list-ui.mjs @@ -85,8 +85,26 @@ try { assert.equal(await dialog.getByRole("button", { name: "Save", exact: true }).isEnabled(), true); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await dialog.waitFor({ state: "hidden" }); + fixture.items = []; + fixture.clients = [{ directive: "deny", address: "all" }]; + await page.reload({ waitUntil: "networkidle" }); + await row.locator("button.dropdown-toggle").click(); + await row.getByRole("button", { name: "Edit", exact: true }).click(); + const denyAllSave = page.waitForResponse( + (response) => + response.request().method() === "PUT" && response.url().includes("/api/nginx/access-lists/900001"), + ); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + const denyAllResponse = await denyAllSave; + assert.equal(denyAllResponse.status(), 400, "deny-all policy must reach the API"); + assert.deepEqual(denyAllResponse.request().postDataJSON().clients, [{ directive: "deny", address: "all" }]); + await dialog.getByText(message, { exact: true }).waitFor(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await dialog.waitFor({ state: "hidden" }); assert.deepEqual(errors, [], "rejected creates must not raise a rollback TypeError"); - console.log("PASS access-list browser validation, masked credentials, and rejected-create recovery"); + console.log( + "PASS access-list browser validation, masked credentials, deny-all policy and rejected-create recovery", + ); } finally { await browser.close(); } diff --git a/backend/.smoke/modal-ui.mjs b/backend/.smoke/modal-ui.mjs index c24c4f5c37..31cf49dd2d 100644 --- a/backend/.smoke/modal-ui.mjs +++ b/backend/.smoke/modal-ui.mjs @@ -137,6 +137,10 @@ try { redirection_hosts: [], dead_hosts: [], streams: [], + mtls_proxy_hosts: [], + mtls_redirection_hosts: [], + mtls_dead_hosts: [], + mtls_streams: [], }; let renewalRequests = 0; await page.route(certificateRoutes, async (route) => { diff --git a/backend/biome.json b/backend/biome.json index 39a847180b..47fce34443 100644 --- a/backend/biome.json +++ b/backend/biome.json @@ -42,6 +42,7 @@ "style": { "noContinue": "off", "noDefaultExport": "off", + "noExcessiveClassesPerFile": "off", "noExcessiveLinesPerFile": "off", "noIncrementDecrement": "off", "noMagicNumbers": "off", diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json index f0bf60715d..47a77861c2 100644 --- a/backend/certbot/dns-plugins.json +++ b/backend/certbot/dns-plugins.json @@ -168,7 +168,7 @@ "package_name": "certbot-dns-easydns" }, "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", + "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", "full_plugin_name": "edgedns", "name": "Akamai Edge DNS", "package_name": "certbot-plugin-edgedns" diff --git a/backend/db.js b/backend/db.js index 1dc474dcd4..fb055772a4 100644 --- a/backend/db.js +++ b/backend/db.js @@ -1,15 +1,9 @@ import knex from "knex"; -import { configGet, configHas } from "./lib/config.js"; +import { configGet } from "./lib/config.js"; let instance = null; const generateDbConfig = () => { - if (!configHas("database")) { - throw new Error( - "Database config does not exist! Please read the instructions: https://github.com/ZoeyVid/NPMplus", - ); - } - const cfg = configGet("database"); if (cfg.engine === "knex-native") { @@ -33,9 +27,7 @@ const generateDbConfig = () => { }; const getInstance = () => { - if (!instance) { - instance = knex(generateDbConfig()); - } + instance ??= knex(generateDbConfig()); return instance; }; diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 8c0413f143..3def27539a 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -7,6 +7,7 @@ import { access as logger } from "../logger.js"; import accessListModel from "../models/access_list.js"; import accessListAuthModel from "../models/access_list_auth.js"; import accessListClientModel from "../models/access_list_client.js"; +import locationModel from "../models/location.js"; import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalNginx from "./nginx.js"; @@ -106,7 +107,11 @@ const internalAccessList = { access.can("access_lists:manage"); const row = await internalAccessList.get(access, { id: data.id, - expand: ["items", "clients", "proxy_hosts.[certificate,access_lists.[clients,items]]"], + expand: [ + "items", + "clients", + "proxy_hosts.[certificate,access_lists.[clients,items],locations.access_lists.[clients,items]]", + ], }); const existingUsernames = new Set((row.items || []).map((item) => item.username)); if ((data.items || []).some((item) => !item.password && !existingUsernames.has(item.username))) { @@ -154,7 +159,11 @@ const internalAccessList = { access, { id: data.id, - expand: ["items", "clients", "proxy_hosts.[certificate,access_lists.[clients,items]]"], + expand: [ + "items", + "clients", + "proxy_hosts.[certificate,access_lists.[clients,items],locations.access_lists.[clients,items]]", + ], }, trx, ); @@ -235,7 +244,9 @@ const internalAccessList = { .where("access_list.is_deleted", 0) .andWhere("access_list.id", thisData.id) .groupBy("access_list.id") - .allowGraph("[items,clients,proxy_hosts.[certificate,access_lists.[clients,items]]]") + .allowGraph( + "[items,clients,proxy_hosts.[certificate,access_lists.[clients,items],locations.access_lists.[clients,items]]]", + ) .first(); if (access.visibility !== "all") { @@ -252,6 +263,9 @@ const internalAccessList = { throw new errs.ItemNotFoundError(thisData.id); } + if (Array.isArray(row.proxy_hosts)) { + row.proxy_hosts = [...new Map(row.proxy_hosts.map((host) => [host.id, host])).values()]; + } return row; }, @@ -266,38 +280,26 @@ const internalAccessList = { access.can("access_lists:manage"); const row = await internalAccessList.get(access, { id: data.id, - expand: ["proxy_hosts.[certificate, access_lists.[clients,items]]", "items", "clients"], + expand: [ + "proxy_hosts.[certificate, access_lists.[clients,items], locations.access_lists.[clients,items]]", + "items", + "clients", + ], }); - - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } // 1. update row to be deleted // 2. update any proxy hosts that were using it (ignoring permissions) // 3. reconfigure those hosts // 4. audit log - // 1. update row to be deleted - await accessListModel.query().where("id", row.id).patch({ - is_deleted: 1, - }); - - // 2. update any proxy hosts that were using it (ignoring permissions) const affectedHosts = (row.proxy_hosts || []).map((host) => { const updatedHost = { ...host }; - // check in case something crazy happened. This should never be the case, but safeguard - if (!Array.isArray(updatedHost.npmplus_access_list_ids)) { - updatedHost.npmplus_access_list_ids = []; - } - updatedHost.npmplus_access_list_ids = updatedHost.npmplus_access_list_ids.filter((id) => id !== row.id); - - // update the access_lists object (separate from the access_lists_ids) + // update the access_lists object if (!Array.isArray(updatedHost.access_lists)) { updatedHost.access_lists = []; } updatedHost.access_lists = updatedHost.access_lists.filter((acl) => acl.id !== row.id); - if (updatedHost.npmplus_access_list_ids.length === 0) { + if (updatedHost.access_lists.length === 0) { updatedHost.npmplus_access_list_type = "public"; } if (!Array.isArray(updatedHost.locations)) { @@ -305,14 +307,9 @@ const internalAccessList = { } updatedHost.locations = updatedHost.locations.map((location) => { const updatedLocation = { ...location }; - if (!Array.isArray(updatedLocation.npmplus_access_list_ids)) { - updatedLocation.npmplus_access_list_ids = []; - } - updatedLocation.npmplus_access_list_ids = updatedLocation.npmplus_access_list_ids.filter( - (id) => id !== row.id, - ); + updatedLocation.access_lists = updatedLocation.access_lists.filter((acl) => acl.id !== row.id); if ( - updatedLocation.npmplus_access_list_ids.length === 0 && + updatedLocation.access_lists.length === 0 && updatedLocation.npmplus_access_list_type === "custom" ) { updatedLocation.npmplus_access_list_type = "global"; @@ -323,57 +320,43 @@ const internalAccessList = { }); const deletedRow = { ...row, proxy_hosts: undefined }; - const finalize = async () => { - // 4. audit log - await internalAuditLog.add(access, { - action: "deleted", - object_type: "access-list", - object_id: row.id, - meta: deletedRow, - }); - }; - try { - // 3. Write the changes to the database and the config - if (affectedHosts.length > 0) { - await proxyHostModel.transaction(async (trx) => { - await Promise.all( - affectedHosts.map(async (host) => { - await proxyHostModel.query(trx).patchAndFetchById(host.id, { - npmplus_access_list_ids: host.npmplus_access_list_ids, - npmplus_access_list_type: host.npmplus_access_list_type, - locations: host.locations, - }); - - return internalProxyHostAccessList.syncAccessListRelations(trx, host.id, host); - }), - ); + await accessListModel.transaction(async (trx) => { + // 1. update row to be deleted + await trx("npmplus_proxy_host_access_list").where("access_list_id", row.id).delete(); + await accessListAuthModel.query(trx).where("access_list_id", row.id).delete(); + await accessListClientModel.query(trx).where("access_list_id", row.id).delete(); + await accessListModel.query(trx).deleteById(row.id); + // 2. update any proxy hosts that were using it (ignoring permissions) + for (const host of affectedHosts) { + await proxyHostModel.query(trx).findById(host.id).patch({ + npmplus_access_list_type: host.npmplus_access_list_type, }); - row.proxy_hosts = affectedHosts; - // step 4. Regenerate configs and htpasswd files - // locations don't have accessList objects, only IDs, so populate it with the object itself - row.proxy_hosts = await Promise.all( - (row.proxy_hosts || []).map((host) => { - const cleanedHost = internalProxyHostAccessList.cleanAccessListTypes(host); - return internalProxyHostAccessList.populateLocationAccessLists(cleanedHost); - }), - ); - await internalNginx.bulkGenerateConfigs(proxyHostModel, "proxy_host", row.proxy_hosts); + for (const location of host.locations) { + await locationModel.query(trx).where("id", location.id).patch({ + npmplus_access_list_type: location.npmplus_access_list_type, + }); + } } + }); + + try { + // 3. Write the changes to the config + // Regenerate configs and htpasswd files + for (const host of affectedHosts) await internalNginx.configure(proxyHostModel, "proxy_host", host); await internalNginx.reload(); // delete the htpasswd file await rm(internalAccessList.getFilename(row), { force: true }); - } catch (operationError) { - // the audit write must never replace the operation's own error - try { - await finalize(); - } catch (cleanupError) { - logger.error(`Error auditing access list delete ${row.id}: ${cleanupError.message}`); - } - throw operationError; + } finally { + // 4. audit log + await internalAuditLog.add(access, { + action: "deleted", + object_type: "access-list", + object_id: row.id, + meta: deletedRow, + }); } - await finalize(); return deletedRow; }, diff --git a/backend/internal/anubis-reporting.js b/backend/internal/anubis-reporting.js index 9c6964c08f..a34a890e23 100644 --- a/backend/internal/anubis-reporting.js +++ b/backend/internal/anubis-reporting.js @@ -155,7 +155,8 @@ const hostCoverage = async (page = 1) => { const query = ProxyHost.query().where("is_deleted", 0).where("enabled", 1); const count = await query.clone().resultSize(); const hosts = await query - .select("id", "domain_names", "npmplus_auth_request", "npmplus_auth_request_upstream", "locations") + .select("id", "domain_names", "npmplus_auth_request", "npmplus_auth_request_upstream") + .withGraphFetched("locations") .orderBy("id") .limit(25) .offset((page - 1) * 25); diff --git a/backend/internal/audit-log.js b/backend/internal/audit-log.js index a314c6608a..baa5e01e58 100644 --- a/backend/internal/audit-log.js +++ b/backend/internal/audit-log.js @@ -28,9 +28,7 @@ const internalAuditLog = { }); } - if (typeof expand !== "undefined" && expand !== null) { - query.withGraphFetched(`[${expand.join(", ")}]`); - } + if (expand) query.withGraphFetched(`[${expand.join(", ")}]`); return query; }, @@ -47,12 +45,9 @@ const internalAuditLog = { const query = auditLogModel.query().andWhere("id", data.id).allowGraph("[user]").first(); - if (typeof data.expand !== "undefined" && data.expand !== null) { - query.withGraphFetched(`[${data.expand.join(", ")}]`); - } + if (data.expand) query.withGraphFetched(`[${data.expand.join(", ")}]`); const row = await query; - if (!row?.id) { throw new errs.ItemNotFoundError(data.id); } @@ -77,9 +72,7 @@ const internalAuditLog = { add: (access, data, transaction) => { data.user_id ||= access.token.getUserId(1); - if (typeof data.action === "undefined" || !data.action) { - throw new errs.InternalValidationError("Audit log entry must contain an Action"); - } + if (!data.action) throw new errs.InternalValidationError("Audit log entry must contain an Action"); // Make sure at least 1 of the IDs are set and action return auditLogModel.query(transaction).insert({ diff --git a/backend/internal/dead-host.js b/backend/internal/dead-host.js index 4ced24f8ad..a5b2017758 100644 --- a/backend/internal/dead-host.js +++ b/backend/internal/dead-host.js @@ -111,7 +111,7 @@ const internalDeadHost = { const existingRow = await internalDeadHost.get(access, { id: thisData.id }); // Get a list of the domain names and check each of them against existing records - if (typeof thisData.domain_names !== "undefined") { + if (thisData.domain_names !== undefined) { const checkResults = await Promise.all( thisData.domain_names.map((domainName) => internalHost.isHostnameTaken(domainName, "dead", thisData.id), @@ -190,28 +190,19 @@ const internalDeadHost = { * @return {Promise} */ get: async (access, data) => { - const thisData = data || {}; - access.can("dead_hosts:view"); - const query = deadHostModel - .query() - .where("is_deleted", 0) - .andWhere("id", thisData.id) - .allowGraph("[certificate]") - .first(); + const query = deadHostModel.query().where("id", data.id).allowGraph("[certificate]").first(); if (access.visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } - if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { - query.withGraphFetched(`[${thisData.expand.join(", ")}]`); - } + if (data.expand) query.withGraphFetched(`[${data.expand.join(", ")}]`); const row = await query; if (!row?.id) { - throw new errs.ItemNotFoundError(thisData.id); + throw new errs.ItemNotFoundError(data.id); } return row; @@ -228,13 +219,8 @@ const internalDeadHost = { access.can("dead_hosts:manage"); const row = await internalDeadHost.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - await deadHostModel.query().where("id", row.id).patch({ - is_deleted: 1, - }); + await deadHostModel.query().deleteById(row.id); const finalize = async () => { // Add to audit log @@ -277,9 +263,6 @@ const internalDeadHost = { id: data.id, expand: ["certificate"], }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (row.enabled) { throw new errs.ValidationError("Host is already enabled"); } @@ -342,15 +325,10 @@ const internalDeadHost = { access.can("dead_hosts:manage"); const row = await internalDeadHost.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (!row.enabled) { throw new errs.ValidationError("Host is already disabled"); } - row.enabled = 0; - await deadHostModel.query().where("id", row.id).patch({ enabled: 0, }); @@ -393,12 +371,11 @@ const internalDeadHost = { * @param {String} [searchQuery] * @returns {Promise} */ - getAll: async (access, expand, searchQuery) => { + getAll: (access, expand, searchQuery) => { access.can("dead_hosts:view"); const query = deadHostModel .query() - .where("is_deleted", 0) .groupBy("id") .allowGraph("[owner,certificate]") .orderBy(castJsonIfNeed("domain_names"), "ASC"); @@ -414,11 +391,9 @@ const internalDeadHost = { }); } - if (typeof expand !== "undefined" && expand !== null) { - query.withGraphFetched(`[${expand.join(", ")}]`); - } + if (expand) query.withGraphFetched(`[${expand.join(", ")}]`); - return await query; + return query; }, /** @@ -428,16 +403,14 @@ const internalDeadHost = { * @param {String} visibility * @returns {Promise} */ - getCount: async (user_id, visibility) => { - const query = deadHostModel.query().count("id as count").where("is_deleted", 0); + getCount: (user_id, visibility) => { + const query = deadHostModel.query(); if (visibility !== "all") { query.andWhere("owner_user_id", user_id); } - const row = await query.first(); - - return Number.parseInt(row.count, 10); + return query.resultSize(); }, }; diff --git a/backend/internal/host.js b/backend/internal/host.js index 7ed2331660..c216863493 100644 --- a/backend/internal/host.js +++ b/backend/internal/host.js @@ -45,6 +45,13 @@ const internalHost = { combinedData.hsts_subdomains = false; } + combinedData.npmplus_mtls_bypass_auth_request &&= + combinedData.npmplus_mtls_certificate_id > 0 && + combinedData.npmplus_mtls_verify_client_optional === true && + [combinedData, ...(combinedData.locations ?? [])].some( + ({ npmplus_auth_request = "none" }) => npmplus_auth_request !== "none", + ); + return combinedData; }, @@ -56,69 +63,24 @@ const internalHost = { * @param {Integer} [ignore_id] Must be supplied if type was also supplied * @returns {Promise} */ - isHostnameTaken: async (hostname, ignore_type, ignore_id) => { - const promises = [ - proxyHostModel - .query() - .where("is_deleted", 0) - .andWhere("enabled", 1) - .andWhere(castJsonIfNeed("domain_names"), "like", `%${hostname}%`), - redirectionHostModel - .query() - .where("is_deleted", 0) - .andWhere("enabled", 1) - .andWhere(castJsonIfNeed("domain_names"), "like", `%${hostname}%`), - deadHostModel - .query() - .where("is_deleted", 0) - .andWhere("enabled", 1) - .andWhere(castJsonIfNeed("domain_names"), "like", `%${hostname}%`), - ]; - - const promises_results = await Promise.all(promises); - let is_taken = false; - - // Proxy Hosts - if ( - promises_results[0] && - checkHostnameRecordsTaken( - hostname, - promises_results[0], - ignore_type === "proxy" && ignore_id ? ignore_id : 0, - ) - ) { - is_taken = true; - } - - // Redirection Hosts - if ( - promises_results[1] && - checkHostnameRecordsTaken( - hostname, - promises_results[1], - ignore_type === "redirection" && ignore_id ? ignore_id : 0, + isHostnameTaken: async (hostname, ignore_type, ignore_id) => ({ + hostname, + is_taken: ( + await Promise.all( + Object.entries({ proxy: proxyHostModel, redirection: redirectionHostModel, dead: deadHostModel }).map( + async ([type, model]) => + checkHostnameRecordsTaken( + hostname, + await model + .query() + .where("enabled", 1) + .andWhere(castJsonIfNeed("domain_names"), "like", `%${hostname}%`), + ignore_type === type && ignore_id ? ignore_id : 0, + ), + ), ) - ) { - is_taken = true; - } - - // Dead Hosts - if ( - promises_results[2] && - checkHostnameRecordsTaken( - hostname, - promises_results[2], - ignore_type === "dead" && ignore_id ? ignore_id : 0, - ) - ) { - is_taken = true; - } - - return { - hostname, - is_taken, - }; - }, + ).includes(true), + }), }; export default internalHost; diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 7fb3eda8e6..dcc9d83dc2 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -7,10 +7,10 @@ import utils from "../lib/utils.js"; import { debug, nginx as logger } from "../logger.js"; import internalProxyHostAccessList from "./proxy-host-access-list.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const __dirname = dirname(fileURLToPath(import.meta.url)); const controlApi = new Client("http://localhost", { connect: { socketPath: "/run/nginx-control.sock" } }); +const authRequestProviders = ["anubis", "tinyauth", "oauth2proxy", "voidauth", "authelia", "authentik"]; const internalNginx = { /** @@ -109,7 +109,7 @@ const internalNginx = { if (host_type === "default") { return "/usr/local/nginx/conf/conf.d/default.conf"; } - return `/data/nginx/${internalNginx.getFileFriendlyHostType(host_type)}/${host_id}.conf`; + return `/data/nginx/${host_type}/${host_id}.conf`; }, /** @@ -135,8 +135,7 @@ const internalNginx = { } if ( - location.forward_host && - location.forward_host.indexOf("/") > -1 && + location.forward_host?.includes("/") && !location.forward_host.startsWith("/") && !location.forward_host.startsWith("unix") ) { @@ -176,8 +175,7 @@ const internalNginx = { if (["http", "https", "grpc", "grpcs"].includes(host.forward_scheme)) { if ( - host.forward_host && - host.forward_host.indexOf("/") > -1 && + host.forward_host?.includes("/") && !host.forward_host.startsWith("/") && !host.forward_host.startsWith("unix") ) { @@ -204,8 +202,7 @@ const internalNginx = { } if ( - location.forward_host && - location.forward_host.indexOf("/") > -1 && + location.forward_host?.includes("/") && !location.forward_host.startsWith("/") && !location.forward_host.startsWith("unix") ) { @@ -232,16 +229,15 @@ const internalNginx = { */ 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); + const host = structuredClone(host_row); const renderEngine = utils.getRenderEngine(); - const filename = internalNginx.getConfigName(nice_host_type, host.id); + const filename = internalNginx.getConfigName(host_type, host.id); let template; try { - template = await utils.getParsedTemplate(`${__dirname}/../templates/${nice_host_type}.conf`); + template = await utils.getParsedTemplate(`${__dirname}/../templates/${host_type}.conf`); } catch (err) { // a broken template load is a config problem the user must see, not a 500 throw new errs.ConfigurationError(err.message); @@ -250,8 +246,7 @@ const internalNginx = { host.env = process.env; if ( - host.forward_host && - host.forward_host.indexOf("/") > -1 && + host.forward_host?.includes("/") && !host.forward_host.startsWith("/") && !host.forward_host.startsWith("unix") ) { @@ -267,14 +262,6 @@ const internalNginx = { // note that there is access_lists -> an array in the correct order // and access_list -> an object used in the config generation // note the (s) in the former and lack thereof in the latter - - // must be ordered by ID as the AccessList constructed is unordered, but for generation it must be. - // the IDs are retrieved in the correct order (specified in the UI) from the DB so they are used for the ordering - const hostAccessLists = Array.isArray(host.access_lists) ? host.access_lists : []; - host.access_lists = internalProxyHostAccessList.orderAccessListsByIds( - hostAccessLists, - host.npmplus_access_list_ids, - ); host.access_list = internalProxyHostAccessList.buildAclFile(host.access_lists); } const hostHtpasswdFileName = internalProxyHostAccessList.getHostFileName(host); @@ -308,13 +295,6 @@ const internalNginx = { // note that there is access_lists -> an array in the correct order // and access_list -> an object used in the config generation // note the (s) in the former and lack thereof in the latter - - // must be ordered by ID as the AccessList constructed is unordered, but for generation it must be. - // the IDs are retrieved in the correct order (specified in the UI) from the DB so they are used for the ordering - location.access_lists = internalProxyHostAccessList.orderAccessListsByIds( - location.access_lists || [], - location.npmplus_access_list_ids, - ); location.access_list = internalProxyHostAccessList.buildAclFile(location.access_lists); } @@ -322,33 +302,18 @@ const internalNginx = { if (htpasswdFileName.length > 0) { location.filename = htpasswdFileName; } - if (location.npmplus_auth_request === "anubis") { - host.create_anubis_locations = true; - } - if (location.npmplus_auth_request === "tinyauth") { - host.create_tinyauth_locations = true; - } - if (location.npmplus_auth_request === "oauth2proxy") { - host.create_oauth2proxy_locations = true; - } - if (location.npmplus_auth_request === "voidauth") { - host.create_voidauth_locations = true; - } - if (location.npmplus_auth_request === "authelia") { - host.create_authelia_locations = true; - } - if ( - location.npmplus_auth_request === "authentik" || + if (location.npmplus_enabled === false) continue; + const provider = location.npmplus_auth_request === "authentik-send-basic-auth" - ) { - host.create_authentik_locations = true; - } + ? "authentik" + : location.npmplus_auth_request; + if (authRequestProviders.includes(provider)) host[`create_${provider}_locations`] = true; } const originalLocations = [...host.locations]; host.locations = await internalNginx.renderLocations(host); - for (const provider of ["anubis", "tinyauth", "oauth2proxy", "voidauth", "authelia", "authentik"]) { + for (const provider of authRequestProviders) { let effectiveUpstream = ""; if ( @@ -360,6 +325,7 @@ const internalNginx = { } else { for (const location of originalLocations) { if ( + location.npmplus_enabled !== false && (location.npmplus_auth_request === provider || (provider === "authentik" && location.npmplus_auth_request === "authentik-send-basic-auth")) && @@ -392,23 +358,13 @@ const internalNginx = { } }, - /** - * - * @param {String} host_type - * @returns String - */ - getFileFriendlyHostType: (host_type) => host_type.replace(/-/g, "_"), - /** * @param {String} host_type * @param {Object} [host] * @returns {Promise} */ deleteConfig: async (host_type, host) => { - const config_file = internalNginx.getConfigName( - internalNginx.getFileFriendlyHostType(host_type), - typeof host === "undefined" ? 0 : host.id, - ); + const config_file = internalNginx.getConfigName(host_type, host?.id ?? 0); await rm(config_file, { force: true }); await rm(`${config_file}.err`, { force: true }); @@ -420,29 +376,10 @@ const internalNginx = { * @returns {Promise} */ renameConfigAsError: async (host_type, host) => { - const config_file = internalNginx.getConfigName( - internalNginx.getFileFriendlyHostType(host_type), - typeof host === "undefined" ? 0 : host.id, - ); + const config_file = internalNginx.getConfigName(host_type, host?.id ?? 0); await rename(config_file, `${config_file}.err`); }, - - /** - * @param {String} hostType - * @param {Array} hosts - * @returns {Promise} - */ - bulkGenerateConfigs: async (model, hostType, hosts, { skipReload = false } = {}) => { - const results = []; - - for (const host of hosts) { - const result = await internalNginx.configure(model, hostType, host, { skipReload }); - results.push(result); - } - - return results; - }, }; export default internalNginx; diff --git a/backend/internal/proxy-host-access-list.js b/backend/internal/proxy-host-access-list.js index abd71f219d..fb33e482fb 100644 --- a/backend/internal/proxy-host-access-list.js +++ b/backend/internal/proxy-host-access-list.js @@ -4,7 +4,6 @@ import errs from "../lib/error.js"; import { access as logger } from "../logger.js"; import accessListModel from "../models/access_list.js"; import internalAccessList from "./access-list.js"; -import internalNginx from "./nginx.js"; const GENERATED_DIR = "/data/access"; @@ -55,8 +54,6 @@ const findHostFiles = async (proxyHost) => { const writeHtpasswdFile = async (filename, items, label) => { logger.info(`Building multi Access file ${filename} for: ${label}`); - await rm(filename, { force: true }); - await internalAccessList.writeData(filename, items); logger.success(`Built merged Access file ${filename} for: ${label}`); }; @@ -73,13 +70,9 @@ const buildHostFile = async (proxyHost, accessLists) => { return; } const effectiveAccess = internalProxyHostAccessList.buildAclFile(accessLists); - const filename = getProxyHostFilename(proxyHost); - if (effectiveAccess.items.length === 0) { - await rm(filename, { force: true }); - return; // don't create an empty file - } - await writeHtpasswdFile(filename, effectiveAccess.items, `proxy host #${proxyHost.id}`); + if (effectiveAccess.items.length === 0) return; // don't create an empty file + await writeHtpasswdFile(getProxyHostFilename(proxyHost), effectiveAccess.items, `proxy host #${proxyHost.id}`); }; /** @@ -95,13 +88,13 @@ const buildLocationFile = async (proxyHost, location, accessLists) => { return; } const effectiveAccess = internalProxyHostAccessList.buildAclFile(accessLists); - const filename = getProxyLocationFilename(proxyHost, location); - if (effectiveAccess.items.length === 0) { - await rm(filename, { force: true }); - return; // don't create an empty file - } - await writeHtpasswdFile(filename, effectiveAccess.items, `proxy host #${proxyHost.id} location #${location.id}`); + if (effectiveAccess.items.length === 0) return; // don't create an empty file + await writeHtpasswdFile( + getProxyLocationFilename(proxyHost, location), + effectiveAccess.items, + `proxy host #${proxyHost.id} location #${location.id}`, + ); }; const internalProxyHostAccessList = { @@ -133,96 +126,12 @@ const internalProxyHostAccessList = { return { items: getMergedItems(lists), clients, - satisfy_any: first ? Boolean(first.satisfy_any) : false, - pass_auth: first ? Boolean(first.pass_auth) : false, + satisfy_any: Boolean(first?.satisfy_any), + pass_auth: Boolean(first?.pass_auth), source_acl_ids: lists.map((l) => l.id).filter((id) => Number.isInteger(id)), }; }, - /** - * Reorders the accessLists objects based on the order of the IDs (which is required for generation) - * @param {*} accessLists - * @param {*} ids - * @returns - */ - orderAccessListsByIds: (accessLists, ids) => { - const byId = new Map((accessLists || []).map((acl) => [acl.id, acl])); - return (ids || []).map((id) => byId.get(id)).filter(Boolean); - }, - - /** - * - * @param {*} proxyHostId - * @param {*} data - * @returns - */ - getAccessListRelationRows: (proxyHostId, data) => { - const relationIds = new Set(); - - // perform data sanitisation - if (data.npmplus_access_list_type === "custom" && Array.isArray(data.npmplus_access_list_ids)) { - for (const id of data.npmplus_access_list_ids) { - if (Number.isInteger(id) && id > 0) { - relationIds.add(id); - } - } - } - - // since locations is stored as a json, extract it and flatten it to store in the join table - if (Array.isArray(data.locations)) { - for (const location of data.locations) { - if (location.npmplus_access_list_type === "custom" && Array.isArray(location.npmplus_access_list_ids)) { - for (const id of location.npmplus_access_list_ids) { - if (Number.isInteger(id) && id > 0) { - relationIds.add(id); - } - } - } - } - } - // now map the acls to proxy hosts - return Array.from(relationIds).map((access_list_id) => ({ - proxy_host_id: proxyHostId, - access_list_id, - })); - }, - - /** - * Updates the npmplus_proxy_host_access_list database table to match the data stored in the object - * @param {*} trx - * @param {*} proxyHostId - * @param {*} data - */ - syncAccessListRelations: async (trx, proxyHostId, data) => { - const desiredRows = internalProxyHostAccessList.getAccessListRelationRows(proxyHostId, data); - const desiredIds = new Set(desiredRows.map((row) => row.access_list_id)); - - const existingRows = await trx("npmplus_proxy_host_access_list") - .where("proxy_host_id", proxyHostId) - .select("access_list_id"); - - const existingIds = new Set(existingRows.map((row) => row.access_list_id)); - - const idsToInsert = [...desiredIds].filter((id) => !existingIds.has(id)); - const idsToDelete = [...existingIds].filter((id) => !desiredIds.has(id)); - - if (idsToInsert.length > 0) { - await trx("npmplus_proxy_host_access_list").insert( - idsToInsert.map((access_list_id) => ({ - proxy_host_id: proxyHostId, - access_list_id, - })), - ); - } - - if (idsToDelete.length > 0) { - await trx("npmplus_proxy_host_access_list") - .where("proxy_host_id", proxyHostId) - .whereIn("access_list_id", idsToDelete) - .delete(); - } - }, - /** * * @param {*} proxyHost @@ -275,29 +184,19 @@ const internalProxyHostAccessList = { * @param {*} proxyHost */ build: async (host_type, proxyHost) => { - if (internalNginx.getFileFriendlyHostType(host_type) !== "proxy_host") { + if (host_type !== "proxy_host") { return; } - // order as specified in the UI - const hostAccessLists = internalProxyHostAccessList.orderAccessListsByIds( - Array.isArray(proxyHost.access_lists) ? proxyHost.access_lists : [], - proxyHost.npmplus_access_list_ids, - ); // cleanup all old files for this host and regenerate await internalProxyHostAccessList.delete(proxyHost); - await buildHostFile(proxyHost, hostAccessLists); + await buildHostFile(proxyHost, proxyHost.access_lists ?? []); for (const location of proxyHost.locations || []) { // global will use the host file name anyway if (location.npmplus_access_list_type === "custom") { - // order as specified in the UI - const locationAccessLists = internalProxyHostAccessList.orderAccessListsByIds( - location.access_lists || [], - location.npmplus_access_list_ids, - ); - await buildLocationFile(proxyHost, location, locationAccessLists); + await buildLocationFile(proxyHost, location, location.access_lists ?? []); } } }, @@ -332,6 +231,7 @@ const internalProxyHostAccessList = { if (!proxyHost || !Array.isArray(proxyHost.locations) || proxyHost.locations.length === 0) { return proxyHost; } + internalProxyHostAccessList.cleanAccessListTypes(proxyHost); const allIds = [ ...new Set( @@ -410,7 +310,7 @@ const internalProxyHostAccessList = { validateCustomSelection(location.npmplus_access_list_type, location.npmplus_access_list_ids); } } - // make sure no ACLs that are being uploaded have been soft deleted + // make sure no ACLs that are being uploaded have been deleted if (aclIds.size > 0) { // canAdmin throws for non-admins; the try/catch turns it into a // plain check (same pattern as lib/nginx-privilege.js). @@ -442,49 +342,20 @@ const internalProxyHostAccessList = { }, /** - * used by the get/update functions of proxy_host and access_list, this sets the npmplus_access_list_ids + * used by the create/update functions of proxy_host, this sets the npmplus_access_list_ids * @param {Object} proxyHost * @returns {Object} */ cleanAccessListTypes(proxyHost) { - if (!proxyHost) { - return proxyHost; - } - - // ensure array exists - if (!Array.isArray(proxyHost.npmplus_access_list_ids)) { - proxyHost.npmplus_access_list_ids = []; - } - - // ensure type exists - if (!proxyHost.npmplus_access_list_type) { - proxyHost.npmplus_access_list_type = "public"; - proxyHost.npmplus_access_list_ids = []; - } + const getAccessListIds = (item) => { + if (item.npmplus_access_list_type === "custom") { + return item.npmplus_access_list_ids ?? item.access_lists?.map(({ id }) => id) ?? []; + } + return []; + }; - if (Array.isArray(proxyHost.locations)) { - // generate the ids for a location (this is only used for the htpasswd file tracking with multi acls) - const existingIds = proxyHost.locations.map((location) => location.id).filter((id) => Number.isInteger(id)); - let count = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 0; - // handle both snake and camel case - proxyHost.locations = proxyHost.locations.map((location) => { - const id = Number.isInteger(location.id) ? location.id : count++; - - const accessListType = location.accessListType || location.npmplus_access_list_type || "global"; - const accessListIds = - accessListType === "custom" - ? [location.accessListIds, location.npmplus_access_list_ids].find(Array.isArray) || [] - : []; - const { accessListIds: _accessListIds, accessListType: _accessListType, ...otherParameters } = location; - - return { - ...otherParameters, - id, - npmplus_access_list_ids: accessListIds, - npmplus_access_list_type: accessListType, - }; - }); - } + proxyHost.npmplus_access_list_ids = getAccessListIds(proxyHost); + for (const location of proxyHost.locations ?? []) location.npmplus_access_list_ids = getAccessListIds(location); return proxyHost; }, }; diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index b47229cb13..914d24170e 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -3,6 +3,7 @@ import errs from "../lib/error.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import { assertPrivilegedNginxFields } from "../lib/nginx-privilege.js"; import { global as logger } from "../logger.js"; +import locationModel from "../models/location.js"; import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; @@ -61,6 +62,31 @@ const configureWithReachability = async (row) => { return status; }; +const replaceLocationsAndAccessLists = async (trx, proxyHostId, data) => { + const insertAccessLists = async (locationId, accessListIds) => { + if (accessListIds.length > 0) { + await trx("npmplus_proxy_host_access_list").insert( + accessListIds.map((access_list_id, position) => ({ + proxy_host_id: proxyHostId, + location_id: locationId, + access_list_id, + position, + })), + ); + } + }; + + await trx("npmplus_proxy_host_access_list").where("proxy_host_id", proxyHostId).delete(); + await locationModel.query(trx).where("proxy_host_id", proxyHostId).delete(); + await insertAccessLists(0, data.npmplus_access_list_ids); + for (const { id, npmplus_access_list_ids: accessListIds, ...location } of data.locations ?? []) { + await insertAccessLists( + (await locationModel.query(trx).insert({ ...location, proxy_host_id: proxyHostId })).id, + accessListIds, + ); + } +}; + const internalProxyHost = { /** * @param {Access} access @@ -101,12 +127,7 @@ const internalProxyHost = { const createdRow = await proxyHostModel.transaction(async (trx) => { const insertedRow = await proxyHostModel.query(trx).insertAndFetch(thisData); - - const relationRows = internalProxyHostAccessList.getAccessListRelationRows(insertedRow.id, thisData); - if (relationRows.length > 0) { - await trx("npmplus_proxy_host_access_list").insert(relationRows); - } - + await replaceLocationsAndAccessLists(trx, insertedRow.id, thisData); return insertedRow; }); @@ -131,15 +152,11 @@ const internalProxyHost = { .patch({ certificate_id: (await internalCertificate.createQuickCertificate(access, thisData)).id }); } - const fetchedRow = await internalProxyHost.get(access, { + const row = await internalProxyHost.get(access, { id: createdRow.id, - expand: ["certificate", "access_lists.[clients,items]"], + expand: ["certificate", "access_lists.[clients,items]", "locations.access_lists.[clients,items]"], }); - const row = await internalProxyHostAccessList.populateLocationAccessLists( - internalProxyHostAccessList.cleanAccessListTypes(fetchedRow), - ); - // Configure nginx await configureWithReachability(row); } catch (operationError) { @@ -181,7 +198,7 @@ const internalProxyHost = { const existingRow = await internalProxyHost.get(access, { id: thisData.id }); // Get a list of the domain names and check each of them against existing records - if (typeof thisData.domain_names !== "undefined") { + if (thisData.domain_names !== undefined) { const checkResults = await Promise.all( thisData.domain_names.map((domainName) => internalHost.isHostnameTaken(domainName, "proxy", thisData.id), @@ -216,11 +233,8 @@ const internalProxyHost = { await internalProxyHostAccessList.validateAccessLists(access, thisData); await proxyHostModel.transaction(async (trx) => { - const patchResult = await proxyHostModel.query(trx).where({ id: thisData.id }).patch(thisData); - - await internalProxyHostAccessList.syncAccessListRelations(trx, thisData.id, thisData); - - return patchResult; + await proxyHostModel.query(trx).where({ id: thisData.id }).patch(thisData); + await replaceLocationsAndAccessLists(trx, thisData.id, thisData); }); let savedRow; @@ -236,15 +250,11 @@ const internalProxyHost = { }); }; try { - const fetchedRow = await internalProxyHost.get(access, { + const row = await internalProxyHost.get(access, { id: thisData.id, - expand: ["certificate", "access_lists.[clients,items]"], + expand: ["certificate", "access_lists.[clients,items]", "locations.access_lists.[clients,items]"], }); - const row = await internalProxyHostAccessList.populateLocationAccessLists( - internalProxyHostAccessList.cleanAccessListTypes(fetchedRow), - ); - // No need to add nginx config if host is disabled if (row.enabled) { // Configure nginx @@ -272,31 +282,27 @@ const internalProxyHost = { * @return {Promise} */ get: async (access, data) => { - const thisData = data || {}; - access.can("proxy_hosts:view"); const query = proxyHostModel .query() - .where("is_deleted", 0) - .andWhere("id", thisData.id) - .allowGraph("[access_lists.[clients,items],certificate]") + .where("id", data.id) + .allowGraph("[access_lists.[clients,items],certificate,locations.access_lists.[clients,items]]") + .withGraphFetched("[access_lists, locations.access_lists]") .first(); if (access.visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } - if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { - query.withGraphFetched(`[${thisData.expand.join(", ")}]`); - } + if (data.expand) query.withGraphFetched(`[${data.expand.join(", ")}]`); const row = await query; if (!row?.id) { - throw new errs.ItemNotFoundError(thisData.id); + throw new errs.ItemNotFoundError(data.id); } - return internalProxyHostAccessList.cleanAccessListTypes(row); + return row; }, /** @@ -310,15 +316,12 @@ const internalProxyHost = { access.can("proxy_hosts:manage"); const row = await internalProxyHost.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - await proxyHostModel.transaction((trx) => - proxyHostModel.query(trx).where("id", row.id).patch({ - is_deleted: 1, - }), - ); + await proxyHostModel.transaction(async (trx) => { + await trx("npmplus_proxy_host_access_list").where("proxy_host_id", row.id).delete(); + await locationModel.query(trx).where("proxy_host_id", row.id).delete(); + await proxyHostModel.query(trx).deleteById(row.id); + }); const finalize = async () => { // Add to audit log @@ -361,11 +364,8 @@ const internalProxyHost = { const row = await internalProxyHost.get(access, { id: data.id, - expand: ["certificate", "access_lists.[clients,items]"], + expand: ["certificate", "access_lists.[clients,items]", "locations.access_lists.[clients,items]"], }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (row.enabled) { throw new errs.ValidationError("Host is already enabled"); } @@ -432,15 +432,10 @@ const internalProxyHost = { access.can("proxy_hosts:manage"); const row = await internalProxyHost.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (!row.enabled) { throw new errs.ValidationError("Host is already disabled"); } - row.enabled = 0; - await proxyHostModel.query().where("id", row.id).patch({ enabled: 0, }); @@ -483,14 +478,14 @@ const internalProxyHost = { * @param {String} [searchQuery] * @returns {Promise} */ - getAll: async (access, expand, searchQuery) => { + getAll: (access, expand, searchQuery) => { access.can("proxy_hosts:view"); const query = proxyHostModel .query() - .where("is_deleted", 0) .groupBy("id") - .allowGraph("[owner,access_lists,certificate]") + .allowGraph("[owner,access_lists,certificate,locations.access_lists]") + .withGraphFetched("[access_lists, locations.access_lists]") .orderBy(castJsonIfNeed("domain_names"), "ASC"); if (access.visibility !== "all") { @@ -504,11 +499,9 @@ const internalProxyHost = { }); } - if (typeof expand !== "undefined" && expand !== null) { - query.withGraphFetched(`[${expand.join(", ")}]`); - } + if (expand) query.withGraphFetched(`[${expand.join(", ")}]`); - return (await query).map((row) => internalProxyHostAccessList.cleanAccessListTypes(row)); + return query; }, /** @@ -518,16 +511,14 @@ const internalProxyHost = { * @param {String} visibility * @returns {Promise} */ - getCount: async (user_id, visibility) => { - const query = proxyHostModel.query().count("id as count").where("is_deleted", 0); + getCount: (user_id, visibility) => { + const query = proxyHostModel.query(); if (visibility !== "all") { query.andWhere("owner_user_id", user_id); } - const row = await query.first(); - - return Number.parseInt(row.count, 10); + return query.resultSize(); }, }; diff --git a/backend/internal/redirection-host.js b/backend/internal/redirection-host.js index 55dd40e72b..bb0c2127b9 100644 --- a/backend/internal/redirection-host.js +++ b/backend/internal/redirection-host.js @@ -111,7 +111,7 @@ const internalRedirectionHost = { const existingRow = await internalRedirectionHost.get(access, { id: thisData.id }); // Get a list of the domain names and check each of them against existing records - if (typeof thisData.domain_names !== "undefined") { + if (thisData.domain_names !== undefined) { const checkResults = await Promise.all( thisData.domain_names.map((domainName) => internalHost.isHostnameTaken(domainName, "redirection", thisData.id), @@ -190,28 +190,19 @@ const internalRedirectionHost = { * @return {Promise} */ get: async (access, data) => { - const thisData = data || {}; - access.can("redirection_hosts:view"); - const query = redirectionHostModel - .query() - .where("is_deleted", 0) - .andWhere("id", thisData.id) - .allowGraph("[certificate]") - .first(); + const query = redirectionHostModel.query().where("id", data.id).allowGraph("[certificate]").first(); if (access.visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } - if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { - query.withGraphFetched(`[${thisData.expand.join(", ")}]`); - } + if (data.expand) query.withGraphFetched(`[${data.expand.join(", ")}]`); const row = await query; if (!row?.id) { - throw new errs.ItemNotFoundError(thisData.id); + throw new errs.ItemNotFoundError(data.id); } return row; @@ -228,13 +219,8 @@ const internalRedirectionHost = { access.can("redirection_hosts:manage"); const row = await internalRedirectionHost.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - await redirectionHostModel.query().where("id", row.id).patch({ - is_deleted: 1, - }); + await redirectionHostModel.query().deleteById(row.id); const finalize = async () => { // Add to audit log @@ -277,9 +263,6 @@ const internalRedirectionHost = { id: data.id, expand: ["certificate"], }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (row.enabled) { throw new errs.ValidationError("Host is already enabled"); } @@ -342,15 +325,10 @@ const internalRedirectionHost = { access.can("redirection_hosts:manage"); const row = await internalRedirectionHost.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (!row.enabled) { throw new errs.ValidationError("Host is already disabled"); } - row.enabled = 0; - await redirectionHostModel.query().where("id", row.id).patch({ enabled: 0, }); @@ -393,12 +371,11 @@ const internalRedirectionHost = { * @param {String} [searchQuery] * @returns {Promise} */ - getAll: async (access, expand, searchQuery) => { + getAll: (access, expand, searchQuery) => { access.can("redirection_hosts:view"); const query = redirectionHostModel .query() - .where("is_deleted", 0) .groupBy("id") .allowGraph("[owner,certificate]") .orderBy(castJsonIfNeed("domain_names"), "ASC"); @@ -414,11 +391,9 @@ const internalRedirectionHost = { }); } - if (typeof expand !== "undefined" && expand !== null) { - query.withGraphFetched(`[${expand.join(", ")}]`); - } + if (expand) query.withGraphFetched(`[${expand.join(", ")}]`); - return await query; + return query; }, /** @@ -428,16 +403,14 @@ const internalRedirectionHost = { * @param {String} visibility * @returns {Promise} */ - getCount: async (user_id, visibility) => { - const query = redirectionHostModel.query().count("id as count").where("is_deleted", 0); + getCount: (user_id, visibility) => { + const query = redirectionHostModel.query(); if (visibility !== "all") { query.andWhere("owner_user_id", user_id); } - const row = await query.first(); - - return Number.parseInt(row.count, 10); + return query.resultSize(); }, }; diff --git a/backend/internal/remote-version.js b/backend/internal/remote-version.js index f1a76fc303..81689cb9b1 100644 --- a/backend/internal/remote-version.js +++ b/backend/internal/remote-version.js @@ -21,7 +21,6 @@ const internalRemoteVersion = { try { if ( !internalRemoteVersion.last_result || - !internalRemoteVersion.last_fetch_time || Date.now() - internalRemoteVersion.last_fetch_time > internalRemoteVersion.cache_timeout ) { const repository = process.env.NPMPLUS_GITHUB_REPOSITORY || "mangyan1/NPMplus"; diff --git a/backend/internal/stream.js b/backend/internal/stream.js index 301e4bc0e8..346748eac1 100644 --- a/backend/internal/stream.js +++ b/backend/internal/stream.js @@ -193,28 +193,19 @@ const internalStream = { * @return {Promise} */ get: async (access, data) => { - const thisData = data || {}; - access.can("streams:view"); - const query = streamModel - .query() - .where("is_deleted", 0) - .andWhere("id", thisData.id) - .allowGraph("[certificate]") - .first(); + const query = streamModel.query().where("id", data.id).allowGraph("[certificate]").first(); if (access.visibility !== "all") { query.andWhere("owner_user_id", access.token.getUserId(1)); } - if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { - query.withGraphFetched(`[${thisData.expand.join(", ")}]`); - } + if (data.expand) query.withGraphFetched(`[${data.expand.join(", ")}]`); const row = await query; if (!row?.id) { - throw new errs.ItemNotFoundError(thisData.id); + throw new errs.ItemNotFoundError(data.id); } return row; @@ -231,13 +222,8 @@ const internalStream = { access.can("streams:manage"); const row = await internalStream.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - await streamModel.query().where("id", row.id).patch({ - is_deleted: 1, - }); + await streamModel.query().deleteById(row.id); const finalize = async () => { // Add to audit log @@ -280,9 +266,6 @@ const internalStream = { id: data.id, expand: ["certificate"], }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (row.enabled) { throw new errs.ValidationError("Stream is already enabled"); } @@ -337,15 +320,10 @@ const internalStream = { access.can("streams:manage"); const row = await internalStream.get(access, { id: data.id }); - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } if (!row.enabled) { throw new errs.ValidationError("Stream is already disabled"); } - row.enabled = 0; - await streamModel.query().where("id", row.id).patch({ enabled: 0, }); @@ -388,12 +366,11 @@ const internalStream = { * @param {String} [searchQuery] * @returns {Promise} */ - getAll: async (access, expand, searchQuery) => { + getAll: (access, expand, searchQuery) => { access.can("streams:view"); const query = streamModel .query() - .where("is_deleted", 0) .groupBy("id") .allowGraph("[owner,certificate]") .orderBy("incoming_port", "ASC"); @@ -412,11 +389,9 @@ const internalStream = { }); } - if (typeof expand !== "undefined" && expand !== null) { - query.withGraphFetched(`[${expand.join(", ")}]`); - } + if (expand) query.withGraphFetched(`[${expand.join(", ")}]`); - return await query; + return query; }, /** @@ -426,16 +401,14 @@ const internalStream = { * @param {String} visibility * @returns {Promise} */ - getCount: async (user_id, visibility) => { - const query = streamModel.query().count("id as count").where("is_deleted", 0); + getCount: (user_id, visibility) => { + const query = streamModel.query(); if (visibility !== "all") { query.andWhere("owner_user_id", user_id); } - const row = await query.first(); - - return Number.parseInt(row.count, 10); + return query.resultSize(); }, }; diff --git a/backend/internal/user.js b/backend/internal/user.js index 1c238482aa..377c48b4fe 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -170,6 +170,11 @@ const internalUser = { // bounded module-level implementation above; no unbounded fetch here fetchGravatar, + deleteAvatarFiles: async (id) => { + await rmAvatars("avatar", id); + await rmAvatars("gravatar", id); + }, + setAvatar: async (access, id, file) => { access.canUser(id); const ext = avatarExt(file?.buffer); diff --git a/backend/lib/certbot.js b/backend/lib/certbot.js index 4eb35940a0..a7e04ce7e4 100644 --- a/backend/lib/certbot.js +++ b/backend/lib/certbot.js @@ -29,25 +29,14 @@ const installPlugin = async (pluginKey) => { * @param {array} pluginKeys */ const installPlugins = async (pluginKeys) => { - if (pluginKeys.length === 0) { - return; - } - - let hasErrors = false; - for (const pluginKey of pluginKeys) { try { await installPlugin(pluginKey); } catch (err) { - hasErrors = true; logger.error(err.message); - break; + throw new errs.CommandError("Some plugins failed to install. Please check the logs above", { cause: err }); } } - - if (hasErrors) { - throw new errs.CommandError("Some plugins failed to install. Please check the logs above"); - } }; export { installPlugin, installPlugins }; diff --git a/backend/lib/config.js b/backend/lib/config.js index 0732388835..d1761ca3cb 100644 --- a/backend/lib/config.js +++ b/backend/lib/config.js @@ -36,7 +36,7 @@ const configure = () => { logger.warn( "MariaDB/MySQL is unsupported, has no advantage over SQLite and is not recommended, note that you can't migrate to SQLite without a fresh install", ); - instance = { + return { database: { engine: mysqlEngine, host: envMysqlHost, @@ -50,7 +50,6 @@ const configure = () => { }, keys: getKeys(), }; - return; } const envPostgresHost = process.env.DB_POSTGRES_HOST || null; @@ -62,7 +61,7 @@ const configure = () => { logger.warn( "PostgreSQL is unsupported, has no advantage over SQLite and is not recommended, note that you can't migrate to SQLite without a fresh install", ); - instance = { + return { database: { engine: postgresEngine, host: envPostgresHost, @@ -73,13 +72,12 @@ const configure = () => { }, keys: getKeys(), }; - return; } const envSqliteFile = testRoot ? join(testRoot, "data/npmplus/database.sqlite") : "/data/npmplus/database.sqlite"; logger.info(`Using Sqlite: ${envSqliteFile}`); - instance = { + return { database: { engine: "knex-native", knex: { @@ -152,27 +150,6 @@ const generateKeys = () => { logger.info(`Wrote JWT key pair to config file: ${keysFile}`); }; -/** - * - * @param {string} key ie: 'database' or 'database.engine' - * @returns {boolean} - */ -const configHas = (key) => { - if (instance === null) configure(); - const keys = key.split("."); - let level = instance; - let has = true; - for (const keyItem of keys) { - if (typeof level[keyItem] === "undefined") { - has = false; - break; - } - level = level[keyItem]; - } - - return has; -}; - /** * Gets a specific key from the top level * @@ -180,10 +157,8 @@ const configHas = (key) => { * @returns {*} */ const configGet = (key) => { - if (instance === null) configure(); - if (key && typeof instance[key] !== "undefined") { - return instance[key]; - } + instance ??= configure(); + if (key && instance[key] !== undefined) return instance[key]; return instance; }; @@ -193,7 +168,7 @@ const configGet = (key) => { * @returns {boolean} */ const isSqlite = () => { - if (instance === null) configure(); + instance ??= configure(); return instance.database.knex?.client === sqliteEngine; }; @@ -203,7 +178,7 @@ const isSqlite = () => { * @returns {boolean} */ const isMysql = () => { - if (instance === null) configure(); + instance ??= configure(); return instance.database.engine === mysqlEngine; }; @@ -213,7 +188,7 @@ const isMysql = () => { * @returns {boolean} */ const isPostgres = () => { - if (instance === null) configure(); + instance ??= configure(); return instance.database.engine === postgresEngine; }; @@ -223,7 +198,7 @@ const isPostgres = () => { * @returns {string} */ const getPublicKey = () => { - if (instance === null) configure(); + instance ??= configure(); return instance.keys.pub; }; @@ -233,8 +208,8 @@ const getPublicKey = () => { * @returns {string} */ const getPrivateKey = () => { - if (instance === null) configure(); + instance ??= configure(); return instance.keys.key; }; -export { configGet, configHas, getPrivateKey, getPublicKey, isMysql, isPostgres, isSqlite }; +export { configGet, getPrivateKey, getPublicKey, isMysql, isPostgres, isSqlite }; diff --git a/backend/lib/error.js b/backend/lib/error.js index d485ac80a2..7dc03fe5d2 100644 --- a/backend/lib/error.js +++ b/backend/lib/error.js @@ -1,75 +1,64 @@ const errs = { - PermissionError: function (message, messageI18n) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message || "Permission Denied"; - this.message_i18n = messageI18n; - this.public = true; - this.status = 403; + PermissionError: class extends Error { + name = this.constructor.name; + public = true; + status = 403; + + constructor(message, messageI18n, options) { + super(message || "Permission Denied", options); + this.message_i18n = messageI18n; + } }, - ItemNotFoundError: function (id) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = "Not Found"; - if (id) { - this.message = `Not Found - ${id}`; + ItemNotFoundError: class extends Error { + name = this.constructor.name; + public = true; + status = 404; + + constructor(id, options) { + super(id ? `Not Found - ${id}` : "Not Found", options); } - this.public = true; - this.status = 404; }, - AuthError: function (message, messageI18n) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message; - this.message_i18n = messageI18n; - this.public = true; - this.status = 401; + AuthError: class extends Error { + name = this.constructor.name; + public = true; + status = 401; + + constructor(message, messageI18n, options) { + super(message, options); + this.message_i18n = messageI18n; + } }, - InternalValidationError: function (message) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message; - this.status = 400; - this.public = false; + InternalValidationError: class extends Error { + name = this.constructor.name; + public = false; + status = 400; }, - ConfigurationError: function (message) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message; - this.status = 400; - this.public = true; + ConfigurationError: class extends Error { + name = this.constructor.name; + public = true; + status = 400; }, - ValidationError: function (message) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message; - this.public = true; - this.status = 400; + ValidationError: class extends Error { + name = this.constructor.name; + public = true; + status = 400; }, - AssertionFailedError: function (message) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message; - this.public = false; - this.status = 400; + AssertionFailedError: class extends Error { + name = this.constructor.name; + public = false; + status = 400; }, - CommandError: function (stdErr) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = stdErr; - this.public = false; + CommandError: class extends Error { + name = this.constructor.name; + public = false; }, }; -for (const err of Object.values(errs)) { - err.prototype = Object.create(Error.prototype); -} - export default errs; diff --git a/backend/lib/express/user-id-from-me.js b/backend/lib/express/user-id-from-me.js index a38414837f..321c020ed7 100644 --- a/backend/lib/express/user-id-from-me.js +++ b/backend/lib/express/user-id-from-me.js @@ -2,8 +2,8 @@ import errs from "../error.js"; export default (req, res, next) => { if (req.params.user_id === "me" && res.locals.access) { - req.params.user_id = res.locals.access.token.get("attrs")?.id; - if (req.params.user_id === undefined) { + req.params.user_id = res.locals.access.token.getUserId(0); + if (!req.params.user_id) { return next(new errs.PermissionError()); } } else { diff --git a/backend/lib/helpers.js b/backend/lib/helpers.js index bd1ef31f58..aaec493cb7 100644 --- a/backend/lib/helpers.js +++ b/backend/lib/helpers.js @@ -33,18 +33,14 @@ const parseDatePeriod = (expression) => { const convertIntFieldsToBool = (obj, fields) => { for (const field of fields) { - if (typeof obj[field] !== "undefined") { - obj[field] = obj[field] === 1; - } + if (obj[field] !== undefined) obj[field] = obj[field] === 1; } return obj; }; const convertBoolFieldsToInt = (obj, fields) => { for (const field of fields) { - if (typeof obj[field] !== "undefined") { - obj[field] = obj[field] ? 1 : 0; - } + if (obj[field] !== undefined) obj[field] = obj[field] ? 1 : 0; } return obj; }; @@ -66,11 +62,18 @@ const removeCertificateFields = (obj) => const jsonReplacer = (key, value) => { if (typeof value !== "string") return value; if (key === "password") return ""; - return ["certificate", "certificate_key", "dns_provider_credentials", "npmplus_dns_provider_credentials"].includes( - key, - ) - ? undefined - : value; + if ( + [ + "certificate", + "certificate_key", + "dns_provider_credentials", + "npmplus_dns_provider_credentials", + "hint", + ].includes(key) + ) { + return; + } + return value; }; /** diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js index 2e27952938..1232e438b8 100644 --- a/backend/lib/validator/api.js +++ b/backend/lib/validator/api.js @@ -44,18 +44,13 @@ const apiValidator = (schema, payload /*, description*/) => { } // Can't use falsy check here as valid payload could be `0` or `false` - if (typeof payload === "undefined") { - throw new errs.ValidationError("Payload is undefined"); - } + if (payload === undefined) throw new errs.ValidationError("Payload is undefined"); const validate = ajv.compile(schema); const data = trimStrings(payload); - const valid = validate(data); - if (valid && !validate.errors) { - return data; - } + if (validate(data)) return data; const message = ajv.errorsText(validate.errors); const err = new errs.ValidationError(message); diff --git a/backend/lib/validator/index.js b/backend/lib/validator/index.js index 78307924d1..a9d66aa888 100644 --- a/backend/lib/validator/index.js +++ b/backend/lib/validator/index.js @@ -17,27 +17,13 @@ const ajv = new Ajv({ * * @param {Object} schema * @param {Object} payload - * @returns {Promise} + * @returns {Object} */ -const validator = (schema, payload) => - new Promise((resolve, reject) => { - if (!payload) { - reject(new errs.InternalValidationError("Payload is falsy")); - } else { - try { - const validate = ajv.compile(schema); - const valid = validate(payload); +const validator = (schema, payload) => { + const validate = ajv.compile(schema); + if (validate(payload)) return structuredClone(payload); - if (valid && !validate.errors) { - resolve(structuredClone(payload)); - } else { - const message = ajv.errorsText(validate.errors); - reject(new errs.ValidationError(message)); - } - } catch (err) { - reject(err); - } - } - }); + throw new errs.ValidationError(ajv.errorsText(validate.errors)); +}; export default validator; diff --git a/backend/migrations/20260913183434_lowercase_emails.js b/backend/migrations/20260913183434_lowercase_emails.js new file mode 100644 index 0000000000..c12bad72b8 --- /dev/null +++ b/backend/migrations/20260913183434_lowercase_emails.js @@ -0,0 +1,34 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "lowercase_emails"; + +/** + * Migrate + * + * @see https://knexjs.org/guide/migrations.html#migration-api + * + * @param {Object} knex + * @returns {Promise} + */ +const up = async (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + for (const { id, email } of await knex("user").select("id", "email")) { + const normalized = email.toLowerCase().trim(); + if (normalized !== email) await knex("user").where("id", id).update({ email: normalized }); + } + + logger.info(`[${migrateName}] emails normalized`); +}; + +/** + * Undo Migrate + * + * @param {Object} _knex + * @returns {Promise} + */ +const down = (_knex) => { + throw new Error(`[${migrateName}] You can't migrate down this one.`); +}; + +export { down, up }; diff --git a/backend/migrations/20260924163000_upstream_location_access_list.js b/backend/migrations/20260924163000_upstream_location_access_list.js index 92ef3401d3..3b5e62affe 100644 --- a/backend/migrations/20260924163000_upstream_location_access_list.js +++ b/backend/migrations/20260924163000_upstream_location_access_list.js @@ -28,11 +28,12 @@ const up = async (knex) => { .where("id", row.id) .update({ locations: JSON.stringify( - locations.map(({ access_list_id: id, ...location }) => - validIds.has(id) - ? { ...location, npmplus_access_list_type: "custom", npmplus_access_list_ids: [id] } - : location, - ), + locations.map(({ access_list_id: id, ...location }) => { + if (validIds.has(id)) { + return { ...location, npmplus_access_list_type: "custom", npmplus_access_list_ids: [id] }; + } + return location; + }), ), }); } diff --git a/backend/migrations/20261008213833_mtls_bypass_auth_request.js b/backend/migrations/20261008213833_mtls_bypass_auth_request.js new file mode 100644 index 0000000000..e0e6a4a03e --- /dev/null +++ b/backend/migrations/20261008213833_mtls_bypass_auth_request.js @@ -0,0 +1,33 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "mtls_bypass_auth_request"; + +/** + * Migrate + * + * @see https://knexjs.org/guide/migrations.html#migration-api + * + * @param {Object} knex + * @returns {Promise} + */ +const up = async (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + await knex.schema.table("proxy_host", (proxy_host) => { + proxy_host.integer("npmplus_mtls_bypass_auth_request").notNull().unsigned().defaultTo(0); + }); + + logger.info(`[${migrateName}] proxy_host Table altered`); +}; + +/** + * Undo Migrate + * + * @param {Object} _knex + * @returns {Promise} + */ +const down = (_knex) => { + throw new Error(`[${migrateName}] You can't migrate down this one.`); +}; + +export { down, up }; diff --git a/backend/migrations/20261010135653_hard_delete.js b/backend/migrations/20261010135653_hard_delete.js new file mode 100644 index 0000000000..248bacdaef --- /dev/null +++ b/backend/migrations/20261010135653_hard_delete.js @@ -0,0 +1,63 @@ +import internalUser from "../internal/user.js"; +import { migrate as logger } from "../logger.js"; + +const migrateName = "hard_delete"; + +/** + * Migrate + * + * @see https://knexjs.org/guide/migrations.html#migration-api + * + * @param {Object} knex + * @returns {Promise} + */ +const up = async (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + await knex("npmplus_proxy_host_access_list") + .whereIn("proxy_host_id", knex("proxy_host").select("id").where("is_deleted", 1)) + .delete(); + + const accessListIds = await knex("access_list").where("is_deleted", 1).pluck("id"); + for (const table of ["npmplus_proxy_host_access_list", "access_list_auth", "access_list_client"]) { + await knex(table).whereIn("access_list_id", accessListIds).delete(); + } + + for (const table of [ + "proxy_host", + "redirection_host", + "dead_host", + "stream", + "access_list", + "certificate", + "auth", + ]) { + await knex(table).where("is_deleted", 1).delete(); + } + + logger.info(`[${migrateName}] deleted rows removed`); + + const userIds = await knex("user").where("is_deleted", 1).pluck("id"); + for (const table of ["auth", "user_permission"]) await knex(table).whereIn("user_id", userIds).delete(); + await knex("user").whereIn("id", userIds).delete(); + for (const id of userIds) await internalUser.deleteAvatarFiles(id); + + logger.info(`[${migrateName}] removed ${userIds.length} deleted users`); + + await knex("user").update({ nickname: "" }); + await knex("proxy_host").update({ access_list_id: 0 }); + + logger.info(`[${migrateName}] unused columns cleared`); +}; + +/** + * Undo Migrate + * + * @param {Object} _knex + * @returns {Promise} + */ +const down = (_knex) => { + throw new Error(`[${migrateName}] You can't migrate down this one.`); +}; + +export { down, up }; diff --git a/backend/migrations/20261011120000_pass_auth_default.js b/backend/migrations/20261011120000_pass_auth_default.js new file mode 100644 index 0000000000..194220445f --- /dev/null +++ b/backend/migrations/20261011120000_pass_auth_default.js @@ -0,0 +1,33 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "pass_auth_default"; + +/** + * Migrate + * + * @see https://knexjs.org/guide/migrations.html#migration-api + * + * @param {Object} knex + * @returns {Promise} + */ +const up = async (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + await knex.schema.table("access_list", (access_list) => { + access_list.integer("pass_auth").notNull().defaultTo(0).alter(); + }); + + logger.info(`[${migrateName}] access_list Table altered`); +}; + +/** + * Undo Migrate + * + * @param {Object} _knex + * @returns {Promise} + */ +const down = (_knex) => { + throw new Error(`[${migrateName}] You can't migrate down this one.`); +}; + +export { down, up }; diff --git a/backend/migrations/20261012163730_npmplus_location.js b/backend/migrations/20261012163730_npmplus_location.js new file mode 100644 index 0000000000..7ea3d7fc4b --- /dev/null +++ b/backend/migrations/20261012163730_npmplus_location.js @@ -0,0 +1,131 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "npmplus_location"; + +const boolFields = [ + "npmplus_noindex", + "npmplus_crowdsec_appsec", + "npmplus_proxy_request_buffering", + "npmplus_proxy_response_buffering", + "npmplus_upstream_compression", + "npmplus_fancyindex", +]; + +/** + * Migrate + * + * @see https://knexjs.org/guide/migrations.html#migration-api + * + * @param {Object} knex + * @returns {Promise} + */ +const up = async (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + await knex.schema.createTable("npmplus_location", (table) => { + table.increments().primary(); + table.integer("proxy_host_id").notNull().unsigned().index(); + table.integer("npmplus_enabled").notNull().unsigned().defaultTo(1); + table.text("path").notNull(); + table.string("location_type").notNull().defaultTo(""); + table.string("forward_scheme").notNull().defaultTo("http"); + table.string("forward_host").notNull().defaultTo(""); + table.integer("forward_port").notNull().unsigned().defaultTo(0); + for (const field of boolFields) table.integer(field).notNull().unsigned().defaultTo(0); + table.string("npmplus_x_frame_options").notNull().defaultTo("SAMEORIGIN"); + table.string("npmplus_auth_request").notNull().defaultTo("none"); + table.string("npmplus_auth_request_upstream").notNull().defaultTo(""); + table.text("advanced_config").notNull().defaultTo(""); + table.string("npmplus_access_list_type").notNull().defaultTo("global"); + }); + + logger.info(`[${migrateName}] npmplus_location Table created`); + + const hosts = await knex("proxy_host").select( + "id", + "locations", + "npmplus_access_list_type", + "npmplus_access_list_ids", + ); + const locations = hosts.flatMap((host) => + (Array.isArray(host.locations) ? host.locations : JSON.parse(host.locations)).map((location) => ({ + ...location, + proxy_host_id: host.id, + })), + ); + + await knex.batchInsert( + "npmplus_location", + locations.map((location) => ({ + proxy_host_id: location.proxy_host_id, + npmplus_enabled: location.npmplus_enabled === false ? 0 : 1, + path: location.path ?? "", + location_type: location.location_type ?? "", + forward_scheme: location.forward_scheme ?? "http", + forward_host: location.forward_host ?? "", + forward_port: Number.parseInt(location.forward_port, 10) || 0, + ...Object.fromEntries(boolFields.map((field) => [field, location[field] ? 1 : 0])), + npmplus_x_frame_options: location.npmplus_x_frame_options ?? "SAMEORIGIN", + npmplus_auth_request: location.npmplus_auth_request ?? "none", + npmplus_auth_request_upstream: location.npmplus_auth_request_upstream ?? "", + advanced_config: location.advanced_config ?? "", + npmplus_access_list_type: location.npmplus_access_list_type ?? "global", + })), + 100, + ); + + logger.info(`[${migrateName}] ${locations.length} locations moved to npmplus_location`); + + await knex.schema.dropTable("npmplus_proxy_host_access_list"); + await knex.schema.createTable("npmplus_proxy_host_access_list", (table) => { + table.integer("proxy_host_id").notNull().unsigned(); + table.integer("location_id").notNull().unsigned().defaultTo(0); + table.integer("access_list_id").notNull().unsigned(); + table.integer("position").notNull().unsigned().defaultTo(0); + table.primary(["proxy_host_id", "location_id", "access_list_id"]); + }); + + const validIds = new Set(await knex("access_list").pluck("id")); + const locationIds = await knex("npmplus_location").orderBy("id").pluck("id"); + const toRelations = ( + proxy_host_id, + location_id, + { npmplus_access_list_type: type, npmplus_access_list_ids: ids }, + ) => { + if (type === "custom") { + return [...new Set(Array.isArray(ids) ? ids : JSON.parse(ids))] + .filter((id) => validIds.has(id)) + .map((access_list_id, position) => ({ proxy_host_id, location_id, access_list_id, position })); + } + return []; + }; + const relations = [ + ...hosts.flatMap((host) => toRelations(host.id, 0, host)), + ...locations.flatMap((location, index) => toRelations(location.proxy_host_id, locationIds[index], location)), + ]; + + await knex.batchInsert("npmplus_proxy_host_access_list", relations, 100); + + logger.info(`[${migrateName}] ${relations.length} access list relations written to npmplus_proxy_host_access_list`); + + await knex("proxy_host").update({ locations: "[]" }); + await knex("proxy_host").whereNull("forward_port").update({ forward_port: 0 }); + await knex.schema.alterTable("proxy_host", (table) => { + table.dropColumn("npmplus_access_list_ids"); + table.integer("forward_port").unsigned().notNull().defaultTo(0).alter(); + }); + + logger.info(`[${migrateName}] proxy_host Table altered`); +}; + +/** + * Undo Migrate + * + * @param {Object} _knex + * @returns {Promise} + */ +const down = (_knex) => { + throw new Error(`[${migrateName}] You can't migrate down this one.`); +}; + +export { down, up }; diff --git a/backend/models/access_list.js b/backend/models/access_list.js index 87fb271927..18c552baeb 100644 --- a/backend/models/access_list.js +++ b/backend/models/access_list.js @@ -37,17 +37,9 @@ class AccessList extends Model { return super.$formatDatabaseJson(thisJson); } - static get name() { - return "AccessList"; - } - - static get tableName() { - return "access_list"; - } + static tableName = "access_list"; - static get jsonAttributes() { - return ["meta"]; - } + static jsonAttributes = ["meta"]; static get relationMappings() { return { @@ -58,9 +50,6 @@ class AccessList extends Model { from: "access_list.owner_user_id", to: "user.id", }, - modify: (qb) => { - qb.where("user.is_deleted", 0); - }, }, items: { relation: Model.HasManyRelation, @@ -89,9 +78,6 @@ class AccessList extends Model { }, to: "proxy_host.id", }, - modify: (qb) => { - qb.where("proxy_host.is_deleted", 0); - }, }, }; } diff --git a/backend/models/access_list_auth.js b/backend/models/access_list_auth.js index df07467584..d573af1869 100644 --- a/backend/models/access_list_auth.js +++ b/backend/models/access_list_auth.js @@ -26,17 +26,9 @@ class AccessListAuth extends Model { return thisJson; } - static get name() { - return "AccessListAuth"; - } + static tableName = "access_list_auth"; - static get tableName() { - return "access_list_auth"; - } - - static get jsonAttributes() { - return ["meta"]; - } + static jsonAttributes = ["meta"]; static get relationMappings() { return { @@ -47,9 +39,6 @@ class AccessListAuth extends Model { from: "access_list_auth.access_list_id", to: "access_list.id", }, - modify: (qb) => { - qb.where("access_list.is_deleted", 0); - }, }, }; } diff --git a/backend/models/access_list_client.js b/backend/models/access_list_client.js index da9d014374..2fbc5f0156 100644 --- a/backend/models/access_list_client.js +++ b/backend/models/access_list_client.js @@ -26,17 +26,9 @@ class AccessListClient extends Model { return thisJson; } - static get name() { - return "AccessListClient"; - } + static tableName = "access_list_client"; - static get tableName() { - return "access_list_client"; - } - - static get jsonAttributes() { - return ["meta"]; - } + static jsonAttributes = ["meta"]; static get relationMappings() { return { @@ -47,9 +39,6 @@ class AccessListClient extends Model { from: "access_list_client.access_list_id", to: "access_list.id", }, - modify: (qb) => { - qb.where("access_list.is_deleted", 0); - }, }, }; } diff --git a/backend/models/audit-log.js b/backend/models/audit-log.js index c4d015bc34..01a47a2716 100644 --- a/backend/models/audit-log.js +++ b/backend/models/audit-log.js @@ -23,17 +23,9 @@ class AuditLog extends Model { this.modified_on = now(); } - static get name() { - return "AuditLog"; - } - - static get tableName() { - return "audit_log"; - } + static tableName = "audit_log"; - static get jsonAttributes() { - return ["meta"]; - } + static jsonAttributes = ["meta"]; static get relationMappings() { return { diff --git a/backend/models/dead_host.js b/backend/models/dead_host.js index 9f2f6d7fa5..5c4c47211f 100644 --- a/backend/models/dead_host.js +++ b/backend/models/dead_host.js @@ -48,17 +48,9 @@ class DeadHost extends Model { return super.$formatDatabaseJson(thisJson); } - static get name() { - return "DeadHost"; - } - - static get tableName() { - return "dead_host"; - } + static tableName = "dead_host"; - static get jsonAttributes() { - return ["domain_names", "meta"]; - } + static jsonAttributes = ["domain_names", "meta"]; static get relationMappings() { return { @@ -69,9 +61,6 @@ class DeadHost extends Model { from: "dead_host.owner_user_id", to: "user.id", }, - modify: (qb) => { - qb.where("user.is_deleted", 0); - }, }, certificate: { relation: Model.HasOneRelation, @@ -80,9 +69,6 @@ class DeadHost extends Model { from: "dead_host.certificate_id", to: "certificate.id", }, - modify: (qb) => { - qb.where("certificate.is_deleted", 0); - }, }, }; } diff --git a/backend/models/location.js b/backend/models/location.js new file mode 100644 index 0000000000..0f616dad05 --- /dev/null +++ b/backend/models/location.js @@ -0,0 +1,57 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +import { Model } from "objection"; +import db from "../db.js"; +import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import AccessList from "./access_list.js"; + +Model.knex(db()); + +const boolFields = [ + "npmplus_enabled", + "npmplus_noindex", + "npmplus_crowdsec_appsec", + "npmplus_proxy_request_buffering", + "npmplus_proxy_response_buffering", + "npmplus_upstream_compression", + "npmplus_fancyindex", +]; + +class Location extends Model { + $beforeInsert() { + this.advanced_config ??= ""; + } + + $parseDatabaseJson(json) { + return convertIntFieldsToBool(super.$parseDatabaseJson(json), boolFields); + } + + $formatDatabaseJson(json) { + return super.$formatDatabaseJson(convertBoolFieldsToInt(json, boolFields)); + } + + static tableName = "npmplus_location"; + + static get relationMappings() { + return { + access_lists: { + relation: Model.ManyToManyRelation, + modelClass: AccessList, + join: { + from: "npmplus_location.id", + through: { + from: "npmplus_proxy_host_access_list.location_id", + to: "npmplus_proxy_host_access_list.access_list_id", + }, + to: "access_list.id", + }, + modify: (qb) => { + qb.orderBy("npmplus_proxy_host_access_list.position"); + }, + }, + }; + } +} + +export default Location; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index dd8fd95ae5..d9b39d58d3 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -6,6 +6,7 @@ import db from "../db.js"; import { convertBoolFieldsToInt, convertIntFieldsToBool, removeCertificateFields } from "../lib/helpers.js"; import AccessList from "./access_list.js"; import Certificate from "./certificate.js"; +import Location from "./location.js"; import now from "./now_helper.js"; import User from "./user.js"; @@ -25,6 +26,7 @@ const boolFields = [ "npmplus_fancyindex", "npmplus_nginx_online", "npmplus_mtls_verify_client_optional", + "npmplus_mtls_bypass_auth_request", ]; class ProxyHost extends Model { @@ -40,13 +42,9 @@ class ProxyHost extends Model { this.advanced_config ??= ""; this.npmplus_location_config ??= ""; this.npmplus_nginx_err ??= ""; - this.locations ??= []; // Default for access list type this.npmplus_access_list_type ??= "public"; - - // Default for access list ids - this.npmplus_access_list_ids ??= []; } $beforeUpdate() { @@ -62,27 +60,23 @@ class ProxyHost extends Model { allow_websocket_upgrade, http2_support, trust_forwarded_proto, + locations, ...thisJson } = super.$parseDatabaseJson(json); return convertIntFieldsToBool(thisJson, boolFields); } $formatDatabaseJson(json) { - const thisJson = convertBoolFieldsToInt(removeCertificateFields(json), boolFields); + const { npmplus_access_list_ids, ...thisJson } = convertBoolFieldsToInt( + removeCertificateFields(json), + boolFields, + ); return super.$formatDatabaseJson(thisJson); } - static get name() { - return "ProxyHost"; - } - - static get tableName() { - return "proxy_host"; - } + static tableName = "proxy_host"; - static get jsonAttributes() { - return ["domain_names", "meta", "locations", "npmplus_access_list_ids"]; - } + static jsonAttributes = ["domain_names", "meta"]; static get relationMappings() { return { @@ -93,9 +87,6 @@ class ProxyHost extends Model { from: "proxy_host.owner_user_id", to: "user.id", }, - modify: (qb) => { - qb.where("user.is_deleted", 0); - }, }, access_lists: { relation: Model.ManyToManyRelation, @@ -109,7 +100,9 @@ class ProxyHost extends Model { to: "access_list.id", }, modify: (qb) => { - qb.where("access_list.is_deleted", 0); + qb.where("npmplus_proxy_host_access_list.location_id", 0).orderBy( + "npmplus_proxy_host_access_list.position", + ); }, }, certificate: { @@ -119,8 +112,16 @@ class ProxyHost extends Model { from: "proxy_host.certificate_id", to: "certificate.id", }, + }, + locations: { + relation: Model.HasManyRelation, + modelClass: Location, + join: { + from: "proxy_host.id", + to: "npmplus_location.proxy_host_id", + }, modify: (qb) => { - qb.where("certificate.is_deleted", 0); + qb.orderBy("npmplus_location.id"); }, }, }; diff --git a/backend/models/redirection_host.js b/backend/models/redirection_host.js index 090222f9dc..0c00efc569 100644 --- a/backend/models/redirection_host.js +++ b/backend/models/redirection_host.js @@ -49,17 +49,9 @@ class RedirectionHost extends Model { return super.$formatDatabaseJson(thisJson); } - static get name() { - return "RedirectionHost"; - } - - static get tableName() { - return "redirection_host"; - } + static tableName = "redirection_host"; - static get jsonAttributes() { - return ["domain_names", "meta"]; - } + static jsonAttributes = ["domain_names", "meta"]; static get relationMappings() { return { @@ -70,9 +62,6 @@ class RedirectionHost extends Model { from: "redirection_host.owner_user_id", to: "user.id", }, - modify: (qb) => { - qb.where("user.is_deleted", 0); - }, }, certificate: { relation: Model.HasOneRelation, @@ -81,9 +70,6 @@ class RedirectionHost extends Model { from: "redirection_host.certificate_id", to: "certificate.id", }, - modify: (qb) => { - qb.where("certificate.is_deleted", 0); - }, }, }; } diff --git a/backend/models/setting.js b/backend/models/setting.js index 6252fc532b..4fb60a45d3 100644 --- a/backend/models/setting.js +++ b/backend/models/setting.js @@ -12,17 +12,9 @@ class Setting extends Model { this.meta ??= {}; } - static get name() { - return "Setting"; - } - - static get tableName() { - return "setting"; - } + static tableName = "setting"; - static get jsonAttributes() { - return ["meta"]; - } + static jsonAttributes = ["meta"]; } export default Setting; diff --git a/backend/models/stream.js b/backend/models/stream.js index 9f77214f63..eb1d2eb5b3 100644 --- a/backend/models/stream.js +++ b/backend/models/stream.js @@ -44,17 +44,9 @@ class Stream extends Model { return super.$formatDatabaseJson(thisJson); } - static get name() { - return "Stream"; - } - - static get tableName() { - return "stream"; - } + static tableName = "stream"; - static get jsonAttributes() { - return ["meta"]; - } + static jsonAttributes = ["meta"]; static get relationMappings() { return { @@ -65,9 +57,6 @@ class Stream extends Model { from: "stream.owner_user_id", to: "user.id", }, - modify: (qb) => { - qb.where("user.is_deleted", 0); - }, }, certificate: { relation: Model.HasOneRelation, @@ -76,9 +65,6 @@ class Stream extends Model { from: "stream.certificate_id", to: "certificate.id", }, - modify: (qb) => { - qb.where("certificate.is_deleted", 0); - }, }, }; } diff --git a/backend/password-reset.js b/backend/password-reset.js index 56ac07dc0a..ccd4036420 100755 --- a/backend/password-reset.js +++ b/backend/password-reset.js @@ -46,7 +46,7 @@ try { const auth = db .prepare( - "SELECT auth.user_id FROM auth JOIN user ON user.id = auth.user_id WHERE auth.type = 'password' AND auth.is_deleted = 0 AND user.is_deleted = 0 AND user.email = ?", + "SELECT auth.user_id FROM auth JOIN user ON user.id = auth.user_id WHERE auth.type = 'password' AND user.email = ?", ) .get(EMAIL); diff --git a/backend/routes/audit-log.js b/backend/routes/audit-log.js index a798a6c9b1..9ecf15e957 100644 --- a/backend/routes/audit-log.js +++ b/backend/routes/audit-log.js @@ -4,6 +4,7 @@ import jwtdecode from "../lib/express/jwt-decode.js"; import validator from "../lib/validator/index.js"; const listSchema = { + type: "object", additionalProperties: false, properties: { expand: { @@ -17,6 +18,7 @@ const listSchema = { }; const eventSchema = { + type: "object", required: ["event_id"], additionalProperties: false, properties: { @@ -49,7 +51,7 @@ router * Retrieve all logs */ .get(async (req, res) => { - const data = await validator(listSchema, { + const data = validator(listSchema, { expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, query: typeof req.query.query === "string" ? req.query.query : null, }); @@ -72,7 +74,7 @@ router * Retrieve a specific entry */ .get(async (req, res) => { - const data = await validator(eventSchema, { + const data = validator(eventSchema, { event_id: req.params.event_id, expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, }); diff --git a/backend/routes/main.js b/backend/routes/main.js index a79cbdb6c2..67bc5ee158 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -85,8 +85,7 @@ router.use("/api/nginx/certificates", certificatesHostsRoutes); * ALL /api/* */ router.all(/(.+)/, (req, _, next) => { - req.params.page = req.params["0"]; - next(new errs.ItemNotFoundError(req.params.page)); + next(new errs.ItemNotFoundError(req.params["0"])); }); export default router; diff --git a/backend/routes/nginx/access_lists.js b/backend/routes/nginx/access_lists.js index 3e8af75283..37e4366469 100644 --- a/backend/routes/nginx/access_lists.js +++ b/backend/routes/nginx/access_lists.js @@ -6,6 +6,7 @@ import validator from "../../lib/validator/index.js"; import { getValidationSchema } from "../../schema/index.js"; const listSchema = { + type: "object", additionalProperties: false, properties: { expand: { @@ -19,6 +20,7 @@ const listSchema = { }; const accessListSchema = { + type: "object", required: ["list_id"], additionalProperties: false, properties: { @@ -51,7 +53,7 @@ router * Retrieve all access-lists */ .get(async (req, res) => { - const data = await validator(listSchema, { + const data = validator(listSchema, { expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, query: typeof req.query.query === "string" ? req.query.query : null, }); @@ -85,7 +87,7 @@ router * Retrieve a specific access-list */ .get(async (req, res) => { - const data = await validator(accessListSchema, { + const data = validator(accessListSchema, { list_id: req.params.list_id, expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, }); diff --git a/backend/routes/nginx/dead_hosts.js b/backend/routes/nginx/dead_hosts.js index f00d005219..27538da674 100644 --- a/backend/routes/nginx/dead_hosts.js +++ b/backend/routes/nginx/dead_hosts.js @@ -6,6 +6,7 @@ import validator from "../../lib/validator/index.js"; import { getValidationSchema } from "../../schema/index.js"; const listSchema = { + type: "object", additionalProperties: false, properties: { expand: { @@ -19,6 +20,7 @@ const listSchema = { }; const hostSchema = { + type: "object", required: ["host_id"], additionalProperties: false, properties: { @@ -47,7 +49,7 @@ router * Retrieve all dead-hosts */ .get(async (req, res) => { - const data = await validator(listSchema, { + const data = validator(listSchema, { expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, query: typeof req.query.query === "string" ? req.query.query : null, }); @@ -81,7 +83,7 @@ router * Retrieve a specific dead-host */ .get(async (req, res) => { - const data = await validator(hostSchema, { + const data = validator(hostSchema, { host_id: req.params.host_id, }); const row = await internalDeadHost.get(res.locals.access, { diff --git a/backend/routes/nginx/proxy_hosts.js b/backend/routes/nginx/proxy_hosts.js index 784b78e2a1..ea2c829fb2 100644 --- a/backend/routes/nginx/proxy_hosts.js +++ b/backend/routes/nginx/proxy_hosts.js @@ -7,6 +7,7 @@ import validator from "../../lib/validator/index.js"; import { getValidationSchema } from "../../schema/index.js"; const listSchema = { + type: "object", additionalProperties: false, properties: { expand: { @@ -20,6 +21,7 @@ const listSchema = { }; const hostSchema = { + type: "object", required: ["host_id"], additionalProperties: false, properties: { @@ -48,7 +50,7 @@ router * Retrieve all proxy-hosts */ .get(async (req, res) => { - const data = await validator(listSchema, { + const data = validator(listSchema, { expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, query: typeof req.query.query === "string" ? req.query.query : null, }); @@ -82,7 +84,7 @@ router * Retrieve a specific proxy-host */ .get(async (req, res) => { - const data = await validator(hostSchema, { + const data = validator(hostSchema, { host_id: req.params.host_id, }); const row = await internalProxyHost.get(res.locals.access, { diff --git a/backend/routes/nginx/redirection_hosts.js b/backend/routes/nginx/redirection_hosts.js index d5ea6899c2..88329776e6 100644 --- a/backend/routes/nginx/redirection_hosts.js +++ b/backend/routes/nginx/redirection_hosts.js @@ -6,6 +6,7 @@ import validator from "../../lib/validator/index.js"; import { getValidationSchema } from "../../schema/index.js"; const listSchema = { + type: "object", additionalProperties: false, properties: { expand: { @@ -19,6 +20,7 @@ const listSchema = { }; const hostSchema = { + type: "object", required: ["host_id"], additionalProperties: false, properties: { @@ -47,7 +49,7 @@ router * Retrieve all redirection-hosts */ .get(async (req, res) => { - const data = await validator(listSchema, { + const data = validator(listSchema, { expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, query: typeof req.query.query === "string" ? req.query.query : null, }); @@ -81,7 +83,7 @@ router * Retrieve a specific redirection-host */ .get(async (req, res) => { - const data = await validator(hostSchema, { + const data = validator(hostSchema, { host_id: req.params.host_id, }); const row = await internalRedirectionHost.get(res.locals.access, { diff --git a/backend/routes/nginx/streams.js b/backend/routes/nginx/streams.js index e3f395fc7b..70794ab49b 100644 --- a/backend/routes/nginx/streams.js +++ b/backend/routes/nginx/streams.js @@ -6,6 +6,7 @@ import validator from "../../lib/validator/index.js"; import { getValidationSchema } from "../../schema/index.js"; const listSchema = { + type: "object", additionalProperties: false, properties: { expand: { @@ -19,6 +20,7 @@ const listSchema = { }; const streamSchema = { + type: "object", required: ["stream_id"], additionalProperties: false, properties: { @@ -47,7 +49,7 @@ router * Retrieve all streams */ .get(async (req, res) => { - const data = await validator(listSchema, { + const data = validator(listSchema, { expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, query: typeof req.query.query === "string" ? req.query.query : null, }); @@ -81,7 +83,7 @@ router * Retrieve a specific stream */ .get(async (req, res) => { - const data = await validator(streamSchema, { + const data = validator(streamSchema, { stream_id: req.params.stream_id, }); const row = await internalStream.get(res.locals.access, { diff --git a/backend/routes/settings.js b/backend/routes/settings.js index 8a9398a69a..f21f77f789 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -6,6 +6,7 @@ import validator from "../lib/validator/index.js"; import { getValidationSchema } from "../schema/index.js"; const settingSchema = { + type: "object", required: ["setting_id"], additionalProperties: false, properties: { @@ -54,7 +55,7 @@ router * Retrieve a specific setting */ .get(async (req, res) => { - const data = await validator(settingSchema, { + const data = validator(settingSchema, { setting_id: req.params.setting_id, }); const row = await internalSetting.get(res.locals.access, { diff --git a/backend/routes/users.js b/backend/routes/users.js index d01816d6b5..112991a6af 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -18,6 +18,7 @@ import { isSetup, removeSetupToken, verifySetupToken } from "../setup.js"; let setupCreationInProgress = false; const listSchema = { + type: "object", additionalProperties: false, properties: { query: { @@ -27,6 +28,7 @@ const listSchema = { }; const userSchema = { + type: "object", required: ["user_id"], additionalProperties: false, properties: { @@ -82,7 +84,7 @@ router * Retrieve all users */ .get(async (req, res) => { - const data = await validator(listSchema, { + const data = validator(listSchema, { query: typeof req.query.query === "string" ? req.query.query : null, }); const users = await internalUser.getAll(res.locals.access, data.query); @@ -150,7 +152,7 @@ router * Retrieve a specific user */ .get(async (req, res) => { - const data = await validator(userSchema, { + const data = validator(userSchema, { user_id: req.params.user_id, expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, }); diff --git a/backend/schema/common.json b/backend/schema/common.json index a8251d0318..29e816e0b6 100644 --- a/backend/schema/common.json +++ b/backend/schema/common.json @@ -147,6 +147,11 @@ "type": "boolean", "example": false }, + "npmplus_mtls_bypass_auth_request": { + "description": "Skip the auth request if the mTLS client verification succeeded", + "type": "boolean", + "example": false + }, "npmplus_noindex": { "description": "Send noindex header and block some user agents", "type": "boolean", @@ -254,6 +259,7 @@ ] }, "npmplus_access_list_ids": { + "writeOnly": true, "type": "array", "items": { "type": "integer", diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 8bec122fd2..985ca4dd02 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -9,7 +9,6 @@ "domain_names", "forward_host", "forward_port", - "npmplus_access_list_ids", "npmplus_access_list_type", "certificate_id", "ssl_forced", @@ -20,6 +19,7 @@ "npmplus_directory", "npmplus_mtls_certificate_id", "npmplus_mtls_verify_client_optional", + "npmplus_mtls_bypass_auth_request", "npmplus_http3_support", "forward_scheme", "enabled", @@ -61,15 +61,12 @@ "example": "127.0.0.1" }, "forward_port": { - "type": [ - "integer", - "null" - ], - "minimum": 1, + "type": "integer", + "minimum": 0, "maximum": 65535, "example": 8080 }, - "npmplus_access_list_ids":{ + "npmplus_access_list_ids": { "$ref": "../common.json#/properties/npmplus_access_list_ids" }, "npmplus_access_list_type": { @@ -153,6 +150,9 @@ "npmplus_mtls_verify_client_optional": { "$ref": "../common.json#/properties/npmplus_mtls_verify_client_optional" }, + "npmplus_mtls_bypass_auth_request": { + "$ref": "../common.json#/properties/npmplus_mtls_bypass_auth_request" + }, "npmplus_http3_support": { "$ref": "../common.json#/properties/npmplus_http3_support" }, @@ -181,7 +181,6 @@ "forward_host", "forward_port", "path", - "npmplus_access_list_ids", "npmplus_access_list_type" ], "additionalProperties": false, @@ -192,6 +191,9 @@ "null" ] }, + "proxy_host_id": { + "$ref": "../common.json#/properties/id" + }, "npmplus_enabled": { "$ref": "../common.json#/properties/enabled" }, @@ -250,11 +252,14 @@ "advanced_config": { "type": "string" }, - "npmplus_access_list_ids":{ + "npmplus_access_list_ids": { "$ref": "../common.json#/properties/npmplus_access_list_ids" }, "npmplus_access_list_type": { "$ref": "../common.json#/properties/npmplus_location_access_list_type" + }, + "access_lists": { + "$ref": "#/properties/access_lists" } } }, @@ -264,7 +269,10 @@ "forward_scheme": "http", "forward_host": "example.com", "forward_port": 80, - "npmplus_access_list_ids": [1, 2], + "npmplus_access_list_ids": [ + 1, + 2 + ], "npmplus_access_list_type": "custom" } ] diff --git a/backend/schema/index.js b/backend/schema/index.js index 0478486b0d..03e69fad22 100644 --- a/backend/schema/index.js +++ b/backend/schema/index.js @@ -1,10 +1,5 @@ -import { dirname } from "node:path"; -import { fileURLToPath } from "node:url"; import $RefParser from "@apidevtools/json-schema-ref-parser"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - let compiledSchema = null; /** @@ -12,11 +7,9 @@ let compiledSchema = null; * and returns the memory cached value */ const getCompiledSchema = async () => { - if (compiledSchema === null) { - compiledSchema = await $RefParser.dereference(`${__dirname}/swagger.json`, { - mutateInputSchema: false, - }); - } + compiledSchema ??= await $RefParser.dereference(`${import.meta.dirname}/swagger.json`, { + mutateInputSchema: false, + }); return compiledSchema; }; @@ -28,19 +21,7 @@ const getCompiledSchema = async () => { * @param {string} method * @returns string|null */ -const getValidationSchema = (path, method) => { - if ( - compiledSchema !== null && - typeof compiledSchema.paths[path] !== "undefined" && - typeof compiledSchema.paths[path][method] !== "undefined" && - typeof compiledSchema.paths[path][method].requestBody !== "undefined" && - typeof compiledSchema.paths[path][method].requestBody.content !== "undefined" && - typeof compiledSchema.paths[path][method].requestBody.content["application/json"] !== "undefined" && - typeof compiledSchema.paths[path][method].requestBody.content["application/json"].schema !== "undefined" - ) { - return compiledSchema.paths[path][method].requestBody.content["application/json"].schema; - } - return null; -}; +const getValidationSchema = (path, method) => + compiledSchema?.paths[path]?.[method]?.requestBody?.content?.["application/json"]?.schema ?? null; export { getCompiledSchema, getValidationSchema }; diff --git a/backend/schema/paths/nginx/proxy-hosts/get.json b/backend/schema/paths/nginx/proxy-hosts/get.json index 44fd37a387..67cf70e58a 100644 --- a/backend/schema/paths/nginx/proxy-hosts/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/get.json @@ -42,7 +42,6 @@ ], "forward_host": "127.0.0.1", "forward_port": 8081, - "npmplus_access_list_ids": [], "npmplus_access_list_type": "public", "certificate_id": 1, "ssl_forced": false, @@ -62,6 +61,7 @@ "npmplus_directory": "", "npmplus_mtls_certificate_id": 0, "npmplus_mtls_verify_client_optional": false, + "npmplus_mtls_bypass_auth_request": false, "npmplus_http3_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/delete.json b/backend/schema/paths/nginx/proxy-hosts/hostID/delete.json index 7675accc5c..72e70ee00d 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/delete.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/delete.json @@ -59,6 +59,7 @@ "npmplus_directory": "", "npmplus_mtls_certificate_id": 0, "npmplus_mtls_verify_client_optional": false, + "npmplus_mtls_bypass_auth_request": false, "npmplus_http3_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/disable/post.json b/backend/schema/paths/nginx/proxy-hosts/hostID/disable/post.json index cbb078656b..7c98b0849b 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/disable/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/disable/post.json @@ -59,6 +59,7 @@ "npmplus_directory": "", "npmplus_mtls_certificate_id": 0, "npmplus_mtls_verify_client_optional": false, + "npmplus_mtls_bypass_auth_request": false, "npmplus_http3_support": false, "forward_scheme": "http", "enabled": false, diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/enable/post.json b/backend/schema/paths/nginx/proxy-hosts/hostID/enable/post.json index 5caa795ac4..274afc9b51 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/enable/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/enable/post.json @@ -59,6 +59,7 @@ "npmplus_directory": "", "npmplus_mtls_certificate_id": 0, "npmplus_mtls_verify_client_optional": false, + "npmplus_mtls_bypass_auth_request": false, "npmplus_http3_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json index 75dfd78828..60ff62e5e9 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json @@ -39,7 +39,6 @@ ], "forward_host": "127.0.0.1", "forward_port": 8080, - "npmplus_access_list_ids": [], "npmplus_access_list_type": "public", "certificate_id": 0, "ssl_forced": false, @@ -59,6 +58,7 @@ "npmplus_directory": "", "npmplus_mtls_certificate_id": 0, "npmplus_mtls_verify_client_optional": false, + "npmplus_mtls_bypass_auth_request": false, "npmplus_http3_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index 818b8a4b04..2176bdb26f 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -107,6 +107,9 @@ "npmplus_mtls_verify_client_optional": { "$ref": "../../../../components/proxy-host-object.json#/properties/npmplus_mtls_verify_client_optional" }, + "npmplus_mtls_bypass_auth_request": { + "$ref": "../../../../components/proxy-host-object.json#/properties/npmplus_mtls_bypass_auth_request" + }, "npmplus_reuse_key": { "$ref": "../../../../components/certificate-object.json#/properties/npmplus_reuse_key" }, @@ -147,7 +150,6 @@ ], "forward_host": "127.0.0.1", "forward_port": 8080, - "npmplus_access_list_ids": [], "npmplus_access_list_type": "public", "certificate_id": 0, "ssl_forced": false, @@ -167,6 +169,7 @@ "npmplus_directory": "", "npmplus_mtls_certificate_id": 0, "npmplus_mtls_verify_client_optional": false, + "npmplus_mtls_bypass_auth_request": false, "npmplus_http3_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index f42d5a6c21..be29f0f554 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -99,6 +99,9 @@ "npmplus_mtls_verify_client_optional": { "$ref": "../../../components/proxy-host-object.json#/properties/npmplus_mtls_verify_client_optional" }, + "npmplus_mtls_bypass_auth_request": { + "$ref": "../../../components/proxy-host-object.json#/properties/npmplus_mtls_bypass_auth_request" + }, "npmplus_reuse_key": { "$ref": "../../../components/certificate-object.json#/properties/npmplus_reuse_key" }, @@ -147,7 +150,6 @@ ], "forward_host": "127.0.0.1", "forward_port": 8080, - "npmplus_access_list_ids": [], "npmplus_access_list_type": "public", "certificate_id": 0, "ssl_forced": false, @@ -167,6 +169,7 @@ "npmplus_directory": "", "npmplus_mtls_certificate_id": 0, "npmplus_mtls_verify_client_optional": false, + "npmplus_mtls_bypass_auth_request": false, "npmplus_http3_support": false, "forward_scheme": "http", "enabled": true, diff --git a/backend/templates/_proxy_host_custom_location.conf b/backend/templates/_proxy_host_custom_location.conf index 0df8725021..26fc9df2fe 100644 --- a/backend/templates/_proxy_host_custom_location.conf +++ b/backend/templates/_proxy_host_custom_location.conf @@ -56,7 +56,7 @@ location {{ location_type }}{{ path }} { {% if npmplus_fancyindex == true %}fancyindex on;{% endif %} - {% if forward_port != null %} + {% if forward_port > 0 %} location ~* [^/]\.php(?:$|/) { fastcgi_split_path_info ^(.*\.php)(/.*)$; try_files $fastcgi_script_name =404; diff --git a/backend/templates/_upstream.conf b/backend/templates/_upstream.conf index f20e469c1b..75c7ce9a27 100644 --- a/backend/templates/_upstream.conf +++ b/backend/templates/_upstream.conf @@ -1,4 +1,4 @@ upstream {{ forward_upstream_name }} { zone {{ forward_upstream_name }} 128k; - server {{ forward_host }}{% if forward_port != null %}:{{ forward_port }}{% endif %} resolve; + server {{ forward_host }}{% if forward_port > 0 %}:{{ forward_port }}{% endif %} resolve; } diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index cbbc5c89fc..6cf39692ce 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -8,6 +8,7 @@ {% assign auth_request_anubis_upstream_resolved = auth_request_anubis_upstream_resolved | default: env.AUTH_REQUEST_ANUBIS_UPSTREAM %} {% assign auth_request_anubis_upstream_host = auth_request_anubis_upstream_resolved | replace: "https://", "" | replace: "http://", "" %} + {% assign auth_request_anubis_upstream_prefix = auth_request_anubis_upstream_host | slice: 0, 3 %} {% if auth_request_anubis_upstream_resolved contains "https://" %}{% assign auth_request_anubis_upstream_scheme = "https://" %}{% else %}{% assign auth_request_anubis_upstream_scheme = "http://" %}{% endif %} {% if auth_request_anubis_upstream_host contains "]:" %} {% assign auth_request_anubis_upstream_sni_name = auth_request_anubis_upstream_host | split: "]:" | first | append: "]" %} @@ -16,21 +17,27 @@ {% endif %} {% assign auth_request_anubis_upstream_port = auth_request_anubis_upstream_host | split: ":" | last %} {% assign auth_request_anubis_upstream_host_header = auth_request_anubis_upstream_sni_name %} - {% if auth_request_anubis_upstream_scheme == "http://" and auth_request_anubis_upstream_port != "80" %} + {% if auth_request_anubis_upstream_prefix == "cu_" %} + {% assign auth_request_anubis_upstream_sni_name = "$host" %} + {% assign auth_request_anubis_upstream_host_header = "$host$is_request_port$request_port" %} + {% elsif auth_request_anubis_upstream_scheme == "http://" and auth_request_anubis_upstream_port != "80" %} {% assign auth_request_anubis_upstream_host_header = auth_request_anubis_upstream_host %} {% elsif auth_request_anubis_upstream_scheme == "https://" and auth_request_anubis_upstream_port != "443" %} {% assign auth_request_anubis_upstream_host_header = auth_request_anubis_upstream_host %} {% endif %} {% if npmplus_auth_request == "anubis" or create_anubis_locations %} - upstream auth_request_anubis_{{ id }} { - zone auth_request_anubis_{{ id }} 128k; - server {{ auth_request_anubis_upstream_host }} resolve; - } + {% if auth_request_anubis_upstream_prefix != "cu_" %} + upstream auth_request_anubis_{{ id }} { + zone auth_request_anubis_{{ id }} 128k; + server {{ auth_request_anubis_upstream_host }} resolve; + } + {% endif %} {% endif %} {% assign auth_request_tinyauth_upstream_resolved = auth_request_tinyauth_upstream_resolved | default: env.AUTH_REQUEST_TINYAUTH_UPSTREAM %} {% assign auth_request_tinyauth_upstream_host = auth_request_tinyauth_upstream_resolved | replace: "https://", "" | replace: "http://", "" %} + {% assign auth_request_tinyauth_upstream_prefix = auth_request_tinyauth_upstream_host | slice: 0, 3 %} {% if auth_request_tinyauth_upstream_resolved contains "https://" %}{% assign auth_request_tinyauth_upstream_scheme = "https://" %}{% else %}{% assign auth_request_tinyauth_upstream_scheme = "http://" %}{% endif %} {% if auth_request_tinyauth_upstream_host contains "]:" %} {% assign auth_request_tinyauth_upstream_sni_name = auth_request_tinyauth_upstream_host | split: "]:" | first | append: "]" %} @@ -39,21 +46,27 @@ {% endif %} {% assign auth_request_tinyauth_upstream_port = auth_request_tinyauth_upstream_host | split: ":" | last %} {% assign auth_request_tinyauth_upstream_host_header = auth_request_tinyauth_upstream_sni_name %} - {% if auth_request_tinyauth_upstream_scheme == "http://" and auth_request_tinyauth_upstream_port != "80" %} + {% if auth_request_tinyauth_upstream_prefix == "cu_" %} + {% assign auth_request_tinyauth_upstream_sni_name = "$host" %} + {% assign auth_request_tinyauth_upstream_host_header = "$host$is_request_port$request_port" %} + {% elsif auth_request_tinyauth_upstream_scheme == "http://" and auth_request_tinyauth_upstream_port != "80" %} {% assign auth_request_tinyauth_upstream_host_header = auth_request_tinyauth_upstream_host %} {% elsif auth_request_tinyauth_upstream_scheme == "https://" and auth_request_tinyauth_upstream_port != "443" %} {% assign auth_request_tinyauth_upstream_host_header = auth_request_tinyauth_upstream_host %} {% endif %} {% if npmplus_auth_request == "tinyauth" or create_tinyauth_locations %} - upstream auth_request_tinyauth_{{ id }} { - zone auth_request_tinyauth_{{ id }} 128k; - server {{ auth_request_tinyauth_upstream_host }} resolve; - } + {% if auth_request_tinyauth_upstream_prefix != "cu_" %} + upstream auth_request_tinyauth_{{ id }} { + zone auth_request_tinyauth_{{ id }} 128k; + server {{ auth_request_tinyauth_upstream_host }} resolve; + } + {% endif %} {% endif %} {% assign auth_request_oauth2proxy_upstream_resolved = auth_request_oauth2proxy_upstream_resolved | default: env.AUTH_REQUEST_OAUTH2PROXY_UPSTREAM %} {% assign auth_request_oauth2proxy_upstream_host = auth_request_oauth2proxy_upstream_resolved | replace: "https://", "" | replace: "http://", "" %} + {% assign auth_request_oauth2proxy_upstream_prefix = auth_request_oauth2proxy_upstream_host | slice: 0, 3 %} {% if auth_request_oauth2proxy_upstream_resolved contains "https://" %}{% assign auth_request_oauth2proxy_upstream_scheme = "https://" %}{% else %}{% assign auth_request_oauth2proxy_upstream_scheme = "http://" %}{% endif %} {% if auth_request_oauth2proxy_upstream_host contains "]:" %} {% assign auth_request_oauth2proxy_upstream_sni_name = auth_request_oauth2proxy_upstream_host | split: "]:" | first | append: "]" %} @@ -62,21 +75,27 @@ {% endif %} {% assign auth_request_oauth2proxy_upstream_port = auth_request_oauth2proxy_upstream_host | split: ":" | last %} {% assign auth_request_oauth2proxy_upstream_host_header = auth_request_oauth2proxy_upstream_sni_name %} - {% if auth_request_oauth2proxy_upstream_scheme == "http://" and auth_request_oauth2proxy_upstream_port != "80" %} + {% if auth_request_oauth2proxy_upstream_prefix == "cu_" %} + {% assign auth_request_oauth2proxy_upstream_sni_name = "$host" %} + {% assign auth_request_oauth2proxy_upstream_host_header = "$host$is_request_port$request_port" %} + {% elsif auth_request_oauth2proxy_upstream_scheme == "http://" and auth_request_oauth2proxy_upstream_port != "80" %} {% assign auth_request_oauth2proxy_upstream_host_header = auth_request_oauth2proxy_upstream_host %} {% elsif auth_request_oauth2proxy_upstream_scheme == "https://" and auth_request_oauth2proxy_upstream_port != "443" %} {% assign auth_request_oauth2proxy_upstream_host_header = auth_request_oauth2proxy_upstream_host %} {% endif %} {% if npmplus_auth_request == "oauth2proxy" or create_oauth2proxy_locations %} - upstream auth_request_oauth2proxy_{{ id }} { - zone auth_request_oauth2proxy_{{ id }} 128k; - server {{ auth_request_oauth2proxy_upstream_host }} resolve; - } + {% if auth_request_oauth2proxy_upstream_prefix != "cu_" %} + upstream auth_request_oauth2proxy_{{ id }} { + zone auth_request_oauth2proxy_{{ id }} 128k; + server {{ auth_request_oauth2proxy_upstream_host }} resolve; + } + {% endif %} {% endif %} {% assign auth_request_voidauth_upstream_resolved = auth_request_voidauth_upstream_resolved | default: env.AUTH_REQUEST_VOIDAUTH_UPSTREAM %} {% assign auth_request_voidauth_upstream_host = auth_request_voidauth_upstream_resolved | replace: "https://", "" | replace: "http://", "" %} + {% assign auth_request_voidauth_upstream_prefix = auth_request_voidauth_upstream_host | slice: 0, 3 %} {% if auth_request_voidauth_upstream_resolved contains "https://" %}{% assign auth_request_voidauth_upstream_scheme = "https://" %}{% else %}{% assign auth_request_voidauth_upstream_scheme = "http://" %}{% endif %} {% if auth_request_voidauth_upstream_host contains "]:" %} {% assign auth_request_voidauth_upstream_sni_name = auth_request_voidauth_upstream_host | split: "]:" | first | append: "]" %} @@ -85,21 +104,27 @@ {% endif %} {% assign auth_request_voidauth_upstream_port = auth_request_voidauth_upstream_host | split: ":" | last %} {% assign auth_request_voidauth_upstream_host_header = auth_request_voidauth_upstream_sni_name %} - {% if auth_request_voidauth_upstream_scheme == "http://" and auth_request_voidauth_upstream_port != "80" %} + {% if auth_request_voidauth_upstream_prefix == "cu_" %} + {% assign auth_request_voidauth_upstream_sni_name = "$host" %} + {% assign auth_request_voidauth_upstream_host_header = "$host$is_request_port$request_port" %} + {% elsif auth_request_voidauth_upstream_scheme == "http://" and auth_request_voidauth_upstream_port != "80" %} {% assign auth_request_voidauth_upstream_host_header = auth_request_voidauth_upstream_host %} {% elsif auth_request_voidauth_upstream_scheme == "https://" and auth_request_voidauth_upstream_port != "443" %} {% assign auth_request_voidauth_upstream_host_header = auth_request_voidauth_upstream_host %} {% endif %} {% if npmplus_auth_request == "voidauth" or create_voidauth_locations %} - upstream auth_request_voidauth_{{ id }} { - zone auth_request_voidauth_{{ id }} 128k; - server {{ auth_request_voidauth_upstream_host }} resolve; - } + {% if auth_request_voidauth_upstream_prefix != "cu_" %} + upstream auth_request_voidauth_{{ id }} { + zone auth_request_voidauth_{{ id }} 128k; + server {{ auth_request_voidauth_upstream_host }} resolve; + } + {% endif %} {% endif %} {% assign auth_request_authelia_upstream_resolved = auth_request_authelia_upstream_resolved | default: env.AUTH_REQUEST_AUTHELIA_UPSTREAM %} {% assign auth_request_authelia_upstream_host = auth_request_authelia_upstream_resolved | replace: "https://", "" | replace: "http://", "" %} + {% assign auth_request_authelia_upstream_prefix = auth_request_authelia_upstream_host | slice: 0, 3 %} {% if auth_request_authelia_upstream_resolved contains "https://" %}{% assign auth_request_authelia_upstream_scheme = "https://" %}{% else %}{% assign auth_request_authelia_upstream_scheme = "http://" %}{% endif %} {% if auth_request_authelia_upstream_host contains "]:" %} {% assign auth_request_authelia_upstream_sni_name = auth_request_authelia_upstream_host | split: "]:" | first | append: "]" %} @@ -108,21 +133,27 @@ {% endif %} {% assign auth_request_authelia_upstream_port = auth_request_authelia_upstream_host | split: ":" | last %} {% assign auth_request_authelia_upstream_host_header = auth_request_authelia_upstream_sni_name %} - {% if auth_request_authelia_upstream_scheme == "http://" and auth_request_authelia_upstream_port != "80" %} + {% if auth_request_authelia_upstream_prefix == "cu_" %} + {% assign auth_request_authelia_upstream_sni_name = "$host" %} + {% assign auth_request_authelia_upstream_host_header = "$host$is_request_port$request_port" %} + {% elsif auth_request_authelia_upstream_scheme == "http://" and auth_request_authelia_upstream_port != "80" %} {% assign auth_request_authelia_upstream_host_header = auth_request_authelia_upstream_host %} {% elsif auth_request_authelia_upstream_scheme == "https://" and auth_request_authelia_upstream_port != "443" %} {% assign auth_request_authelia_upstream_host_header = auth_request_authelia_upstream_host %} {% endif %} {% if npmplus_auth_request == "authelia" or create_authelia_locations %} - upstream auth_request_authelia_{{ id }} { - zone auth_request_authelia_{{ id }} 128k; - server {{ auth_request_authelia_upstream_host }} resolve; - } + {% if auth_request_authelia_upstream_prefix != "cu_" %} + upstream auth_request_authelia_{{ id }} { + zone auth_request_authelia_{{ id }} 128k; + server {{ auth_request_authelia_upstream_host }} resolve; + } + {% endif %} {% endif %} {% assign auth_request_authentik_upstream_resolved = auth_request_authentik_upstream_resolved | default: env.AUTH_REQUEST_AUTHENTIK_UPSTREAM %} {% assign auth_request_authentik_upstream_host = auth_request_authentik_upstream_resolved | replace: "https://", "" | replace: "http://", "" %} + {% assign auth_request_authentik_upstream_prefix = auth_request_authentik_upstream_host | slice: 0, 3 %} {% if auth_request_authentik_upstream_resolved contains "https://" %}{% assign auth_request_authentik_upstream_scheme = "https://" %}{% else %}{% assign auth_request_authentik_upstream_scheme = "http://" %}{% endif %} {% if auth_request_authentik_upstream_host contains "]:" %} {% assign auth_request_authentik_upstream_sni_name = auth_request_authentik_upstream_host | split: "]:" | first | append: "]" %} @@ -131,17 +162,22 @@ {% endif %} {% assign auth_request_authentik_upstream_port = auth_request_authentik_upstream_host | split: ":" | last %} {% assign auth_request_authentik_upstream_host_header = auth_request_authentik_upstream_sni_name %} - {% if auth_request_authentik_upstream_scheme == "http://" and auth_request_authentik_upstream_port != "80" %} + {% if auth_request_authentik_upstream_prefix == "cu_" %} + {% assign auth_request_authentik_upstream_sni_name = "$host" %} + {% assign auth_request_authentik_upstream_host_header = "$host$is_request_port$request_port" %} + {% elsif auth_request_authentik_upstream_scheme == "http://" and auth_request_authentik_upstream_port != "80" %} {% assign auth_request_authentik_upstream_host_header = auth_request_authentik_upstream_host %} {% elsif auth_request_authentik_upstream_scheme == "https://" and auth_request_authentik_upstream_port != "443" %} {% assign auth_request_authentik_upstream_host_header = auth_request_authentik_upstream_host %} {% endif %} {% if npmplus_auth_request == "authentik" or npmplus_auth_request == "authentik-send-basic-auth" or create_authentik_locations %} - upstream auth_request_authentik_{{ id }} { - zone auth_request_authentik_{{ id }} 128k; - server {{ auth_request_authentik_upstream_host }} resolve; - } + {% if auth_request_authentik_upstream_prefix != "cu_" %} + upstream auth_request_authentik_{{ id }} { + zone auth_request_authentik_{{ id }} 128k; + server {{ auth_request_authentik_upstream_host }} resolve; + } + {% endif %} {% endif %} server { @@ -204,7 +240,7 @@ {% if npmplus_fancyindex == true %}fancyindex on;{% endif %} - {% if forward_port != null %} + {% if forward_port > 0 %} location ~* [^/]\.php(?:$|/) { fastcgi_split_path_info ^(.*\.php)(/.*)$; try_files $fastcgi_script_name =404; @@ -326,14 +362,16 @@ {% if npmplus_auth_request == "anubis" or create_anubis_locations %} location /.within.website/ { + {% if npmplus_mtls_bypass_auth_request == true %}if ($ssl_client_verify = "SUCCESS") { return 200; }{% endif %} include proxy-headers.conf; proxy_ssl_name {{ auth_request_anubis_upstream_sni_name }}; proxy_set_header Host {{ auth_request_anubis_upstream_host_header }}; proxy_set_header Accept-Encoding ""; - proxy_pass {{ auth_request_anubis_upstream_scheme }}auth_request_anubis_{{ id }}; + proxy_pass {{ auth_request_anubis_upstream_scheme }}{% if auth_request_anubis_upstream_prefix == "cu_" %}{{ auth_request_anubis_upstream_host }}{% else %}auth_request_anubis_{{ id }}{% endif %}; proxy_method GET; proxy_pass_request_body off; proxy_set_header Content-Length ""; + proxy_set_header Content-Type ""; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Forwarded-Uri $request_uri; } @@ -346,15 +384,17 @@ {% if npmplus_auth_request == "tinyauth" or create_tinyauth_locations %} location = /tinyauth { + {% if npmplus_mtls_bypass_auth_request == true %}if ($ssl_client_verify = "SUCCESS") { return 200; }{% endif %} include proxy-headers.conf; proxy_ssl_name {{ auth_request_tinyauth_upstream_sni_name }}; proxy_set_header Host {{ auth_request_tinyauth_upstream_host_header }}; proxy_set_header Accept-Encoding ""; - proxy_pass {{ auth_request_tinyauth_upstream_scheme }}auth_request_tinyauth_{{ id }}/api/auth/nginx; + proxy_pass {{ auth_request_tinyauth_upstream_scheme }}{% if auth_request_tinyauth_upstream_prefix == "cu_" %}{{ auth_request_tinyauth_upstream_host }}{% else %}auth_request_tinyauth_{{ id }}{% endif %}/api/auth/nginx; internal; proxy_method GET; proxy_pass_request_body off; proxy_set_header Content-Length ""; + proxy_set_header Content-Type ""; proxy_set_header X-Forwarded-URI $request_uri; } {% endif %} @@ -365,19 +405,21 @@ proxy_ssl_name {{ auth_request_oauth2proxy_upstream_sni_name }}; proxy_set_header Host {{ auth_request_oauth2proxy_upstream_host_header }}; proxy_set_header Accept-Encoding ""; - proxy_pass {{ auth_request_oauth2proxy_upstream_scheme }}auth_request_oauth2proxy_{{ id }}; + proxy_pass {{ auth_request_oauth2proxy_upstream_scheme }}{% if auth_request_oauth2proxy_upstream_prefix == "cu_" %}{{ auth_request_oauth2proxy_upstream_host }}{% else %}auth_request_oauth2proxy_{{ id }}{% endif %}; proxy_set_header X-Auth-Request-Redirect $scheme://$host$is_request_port$request_port$request_uri; } location = /oauth2/auth { + {% if npmplus_mtls_bypass_auth_request == true %}if ($ssl_client_verify = "SUCCESS") { return 200; }{% endif %} include proxy-headers.conf; proxy_ssl_name {{ auth_request_oauth2proxy_upstream_sni_name }}; proxy_set_header Host {{ auth_request_oauth2proxy_upstream_host_header }}; proxy_set_header Accept-Encoding ""; - proxy_pass {{ auth_request_oauth2proxy_upstream_scheme }}auth_request_oauth2proxy_{{ id }}; + proxy_pass {{ auth_request_oauth2proxy_upstream_scheme }}{% if auth_request_oauth2proxy_upstream_prefix == "cu_" %}{{ auth_request_oauth2proxy_upstream_host }}{% else %}auth_request_oauth2proxy_{{ id }}{% endif %}; internal; proxy_method GET; proxy_pass_request_body off; proxy_set_header Content-Length ""; + proxy_set_header Content-Type ""; proxy_set_header X-Forwarded-Uri $request_uri; } location @oauth2_signin { @@ -388,15 +430,17 @@ {% if npmplus_auth_request == "voidauth" or create_voidauth_locations %} location = /api/authz/auth-request { + {% if npmplus_mtls_bypass_auth_request == true %}if ($ssl_client_verify = "SUCCESS") { return 200; }{% endif %} include proxy-headers.conf; proxy_ssl_name {{ auth_request_voidauth_upstream_sni_name }}; proxy_set_header Host {{ auth_request_voidauth_upstream_host_header }}; proxy_set_header Accept-Encoding ""; - proxy_pass {{ auth_request_voidauth_upstream_scheme }}auth_request_voidauth_{{ id }}/api/authz/auth-request; + proxy_pass {{ auth_request_voidauth_upstream_scheme }}{% if auth_request_voidauth_upstream_prefix == "cu_" %}{{ auth_request_voidauth_upstream_host }}{% else %}auth_request_voidauth_{{ id }}{% endif %}/api/authz/auth-request; internal; proxy_method GET; proxy_pass_request_body off; proxy_set_header Content-Length ""; + proxy_set_header Content-Type ""; proxy_set_header X-Forwarded-URI $request_uri; proxy_set_header X-Original-URL $scheme://$host$is_request_port$request_port$request_uri; } @@ -404,15 +448,17 @@ {% if npmplus_auth_request == "authelia" or create_authelia_locations %} location = /internal/authelia/authz { + {% if npmplus_mtls_bypass_auth_request == true %}if ($ssl_client_verify = "SUCCESS") { return 200; }{% endif %} include proxy-headers.conf; proxy_ssl_name {{ auth_request_authelia_upstream_sni_name }}; proxy_set_header Host {{ auth_request_authelia_upstream_host_header }}; proxy_set_header Accept-Encoding ""; - proxy_pass {{ auth_request_authelia_upstream_scheme }}auth_request_authelia_{{ id }}/api/authz/auth-request; + proxy_pass {{ auth_request_authelia_upstream_scheme }}{% if auth_request_authelia_upstream_prefix == "cu_" %}{{ auth_request_authelia_upstream_host }}{% else %}auth_request_authelia_{{ id }}{% endif %}/api/authz/auth-request; internal; proxy_method GET; proxy_pass_request_body off; proxy_set_header Content-Length ""; + proxy_set_header Content-Type ""; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$host$is_request_port$request_port$request_uri; } @@ -420,16 +466,18 @@ {% if npmplus_auth_request == "authentik" or npmplus_auth_request == "authentik-send-basic-auth" or create_authentik_locations %} location /outpost.goauthentik.io { + {% if npmplus_mtls_bypass_auth_request == true %}if ($ssl_client_verify = "SUCCESS") { return 200; }{% endif %} include proxy-headers.conf; proxy_ssl_name {{ auth_request_authentik_upstream_sni_name }}; proxy_set_header Host {{ auth_request_authentik_upstream_host_header }}; proxy_set_header Accept-Encoding ""; - proxy_pass {{ auth_request_authentik_upstream_scheme }}auth_request_authentik_{{ id }}; + proxy_pass {{ auth_request_authentik_upstream_scheme }}{% if auth_request_authentik_upstream_prefix == "cu_" %}{{ auth_request_authentik_upstream_host }}{% else %}auth_request_authentik_{{ id }}{% endif %}; auth_request_set $auth_cookie $upstream_http_set_cookie; add_header Set-Cookie $auth_cookie; proxy_method GET; proxy_pass_request_body off; proxy_set_header Content-Length ""; + proxy_set_header Content-Type ""; proxy_set_header X-Original-URL $scheme://$host$is_request_port$request_port$request_uri; } location @goauthentik_proxy_signin { diff --git a/backend/test/access-list-recovery.test.js b/backend/test/access-list-recovery.test.js index 112326e5de..7907fce069 100644 --- a/backend/test/access-list-recovery.test.js +++ b/backend/test/access-list-recovery.test.js @@ -11,6 +11,7 @@ import { migrateUp } from "../migrate.js"; import AccessList from "../models/access_list.js"; import AccessListAuth from "../models/access_list_auth.js"; import AccessListClient from "../models/access_list_client.js"; +import Location from "../models/location.js"; import ProxyHost from "../models/proxy_host.js"; await migrateUp(); @@ -142,11 +143,24 @@ const attach = async (list) => { }, ], }); + const location = await Location.query().insertAndFetch({ + proxy_host_id: host.id, + path: "/private", + forward_scheme: "http", + forward_host: "127.0.0.1", + forward_port: 8080, + npmplus_access_list_type: "custom", + }); + host.locations = [location]; await db()("npmplus_proxy_host_access_list").insert([ - { proxy_host_id: host.id, access_list_id: list.id }, - { proxy_host_id: host.id, access_list_id: second.id }, + { proxy_host_id: host.id, location_id: 0, access_list_id: list.id, position: 0 }, + { proxy_host_id: host.id, location_id: 0, access_list_id: second.id, position: 1 }, + { proxy_host_id: host.id, location_id: location.id, access_list_id: list.id, position: 0 }, + { proxy_host_id: host.id, location_id: location.id, access_list_id: second.id, position: 1 }, ]); - const expanded = await ProxyHost.query().findById(host.id).withGraphFetched("[access_lists.[items,clients]]"); + const expanded = await ProxyHost.query() + .findById(host.id) + .withGraphFetched("[access_lists.[items,clients],locations.access_lists.[items,clients]]"); await proxyAccess.populateLocationAccessLists(expanded); await internalAccessList.build(await internalAccessList.get(access, { id: list.id, expand: ["items", "clients"] })); await proxyAccess.build("proxy_host", expanded); @@ -161,7 +175,7 @@ for (const phase of ["generate", "validate", "reload", "audit"]) { const files = [ internalAccessList.getFilename(list), `/data/access/host-${host.id}`, - `/data/access/host-${host.id}-location-1`, + `/data/access/host-${host.id}-location-${host.locations[0].id}`, internalNginx.getConfigName("proxy_host", host.id), ]; const before = await Promise.all(files.map((filename) => readFile(filename, "utf8"))); @@ -207,7 +221,7 @@ test("a successful replacement updates attached host and custom-location files a for (const filename of [ `/data/access/${list.id}`, `/data/access/host-${host.id}`, - `/data/access/host-${host.id}-location-1`, + `/data/access/host-${host.id}-location-${host.locations[0].id}`, ]) assert.match(await readFile(filename, "utf8"), /^replacement:/m); assert.equal((await ProxyHost.query().findById(host.id)).npmplus_nginx_online, true); @@ -235,7 +249,7 @@ test("hosts attached after initial validation are included in failure recovery", files = [ internalNginx.getConfigName("proxy_host", host.id), `/data/access/host-${host.id}`, - `/data/access/host-${host.id}-location-1`, + `/data/access/host-${host.id}-location-${host.locations[0].id}`, ]; before = await Promise.all(files.map((filename) => readFile(filename, "utf8"))); } diff --git a/backend/test/anubis-history.test.js b/backend/test/anubis-history.test.js index ff75f7a19d..52d0f7c577 100644 --- a/backend/test/anubis-history.test.js +++ b/backend/test/anubis-history.test.js @@ -92,8 +92,8 @@ test("host coverage follows location inheritance and omits deleted or disabled h { path: "/api", npmplus_auth_request: "tinyauth" }, ], }; - await ProxyHost.query().insert(host); - await ProxyHost.query().insert({ ...host, enabled: false }); + await ProxyHost.query().insertGraph(host); + await ProxyHost.query().insertGraph({ ...host, enabled: false }); const result = await hostCoverage(); assert.equal(result.total, 1); assert.equal(result.items[0].anubis, true); diff --git a/backend/test/api.test.js b/backend/test/api.test.js index 8effb719bd..7dc8facb3e 100644 --- a/backend/test/api.test.js +++ b/backend/test/api.test.js @@ -11,6 +11,7 @@ import crypto from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import net from "node:net"; import { after, test } from "node:test"; +import { generate } from "otplib"; import internalAuditLog from "../internal/audit-log.js"; import crsControl from "../internal/crs-control.js"; @@ -1194,3 +1195,37 @@ test("access-list usernames reject record delimiters and preserve ordinary crede ]); assert.equal(readFileSync("/data/access/legacy-audit", "utf8"), "valid:synthetic-hash\n"); }); + +test("MFA changes await the new session before setting the enrollment and backup-code cookies", async (t) => { + const email = "mfa-session-refresh@example.test"; + const password = "Mfa-Session-Fixture-1"; + const user = await insertUser({ email, password, roles: [] }); + const login = await api("POST", "/api/tokens", { + body: { identity: email, secret: password }, + headers: { "x-forwarded-for": "192.0.2.166" }, + }); + assert.equal(login.status, 200, login.text); + const oldCookie = sessionCookieOf(login); + const setup = await api("POST", `/api/users/${user.id}/mfa/totp`, { cookie: oldCookie }); + assert.equal(setup.status, 200, setup.text); + const enabled = await api("POST", `/api/users/${user.id}/mfa/totp/enable`, { + cookie: oldCookie, + body: { code: await generate({ secret: setup.body.secret }) }, + }); + assert.equal(enabled.status, 200, enabled.text); + const enabledCookie = sessionCookieOf(enabled); + assert.ok(enabledCookie); + assert.equal((await api("GET", "/api/users/me", { cookie: enabledCookie })).status, 200); + assert.equal((await api("GET", "/api/users/me", { cookie: oldCookie })).status, 401); + // Enrollment consumed its code; advance the clock without bypassing real verification. + const realNow = Date.now; + t.mock.method(Date, "now", () => realNow() + 31000); + const regenerated = await api("POST", `/api/users/${user.id}/mfa/backup-codes`, { + cookie: enabledCookie, + body: { code: await generate({ secret: setup.body.secret }) }, + }); + assert.equal(regenerated.status, 200, regenerated.text); + assert.equal(regenerated.body.backup_codes.length, 8); + assert.equal((await api("GET", "/api/users/me", { cookie: sessionCookieOf(regenerated) })).status, 200); + assert.equal((await api("GET", "/api/users/me", { cookie: enabledCookie })).status, 401); +}); diff --git a/backend/test/upstream-location-upgrade.test.js b/backend/test/upstream-location-upgrade.test.js new file mode 100644 index 0000000000..44fa8d6311 --- /dev/null +++ b/backend/test/upstream-location-upgrade.test.js @@ -0,0 +1,134 @@ +import "./helpers/environment.js"; +import assert from "node:assert/strict"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import knex from "knex"; +import db from "../db.js"; +import { hostCoverage } from "../internal/anubis-reporting.js"; +import { migrateUp } from "../migrate.js"; +import ProxyHost from "../models/proxy_host.js"; +import { root } from "./helpers/environment.js"; + +test("a populated fork database keeps ordered location ACLs, Anubis coverage and a restorable legacy snapshot", async (t) => { + const files = (await readdir(new URL("../migrations/", import.meta.url))) + .filter( + (name) => name.endsWith(".js") && (name < "20261008000000" || name === "20261402145603_http3_support.js"), + ) + .sort(); + const legacy = knex(db().client.config); + t.after(() => legacy.destroy()); + await legacy.migrate.latest({ + tableName: "migrations", + migrationSource: { + getMigrations: async () => files, + getMigrationName: (name) => name, + getMigration: (name) => import(new URL(`../migrations/${name}`, import.meta.url)), + }, + }); + const stamp = { created_on: "2026-10-01 00:00:00", modified_on: "2026-10-01 00:00:00" }; + await legacy("user").insert({ + id: 1, + ...stamp, + name: "Upgrade fixture", + email: "upgrade@example.test", + roles: '["admin"]', + avatar: "", + nickname: "", + }); + await legacy("access_list").insert( + [11, 12].map((id) => ({ id, ...stamp, owner_user_id: 1, name: `ACL ${id}`, meta: "{}" })), + ); + const locations = [ + { + id: 7, + path: "/private", + npmplus_auth_request: "anubis", + npmplus_access_list_type: "custom", + npmplus_access_list_ids: [12, 11], + forward_host: "origin.example.test", + forward_port: 8080, + }, + { + id: 8, + path: "/inherit", + npmplus_auth_request: "none", + npmplus_access_list_type: "global", + npmplus_access_list_ids: [], + }, + ]; + await legacy("proxy_host").insert({ + id: 21, + ...stamp, + owner_user_id: 1, + domain_names: '["upgrade.example.test"]', + forward_scheme: "http", + forward_host: "origin.example.test", + forward_port: 8080, + meta: "{}", + locations: JSON.stringify(locations), + npmplus_auth_request: "anubis", + npmplus_access_list_type: "custom", + npmplus_access_list_ids: "[11]", + }); + await legacy("npmplus_proxy_host_access_list").insert({ proxy_host_id: 21, access_list_id: 11 }); + await legacy("user").insert({ + id: 2, + ...stamp, + name: "Deleted fixture", + email: "deleted@example.test", + roles: "[]", + is_deleted: 1, + avatar: "", + nickname: "", + }); + await legacy("auth").insert({ ...stamp, user_id: 2, type: "oidc", secret: "deleted-fixture", meta: "{}" }); + const snapshot = path.join(root, "pre-location-upgrade.sqlite"); + const connection = await legacy.client.acquireConnection(); + await connection.backup(snapshot); + await legacy.client.releaseConnection(connection); + await legacy.destroy(); + + await migrateUp(); + const host = await ProxyHost.query().findById(21).withGraphFetched("[access_lists,locations.access_lists]"); + assert.deepEqual( + host.access_lists.map(({ id }) => id), + [11], + ); + assert.deepEqual( + host.locations.map(({ path: locationPath }) => locationPath), + ["/private", "/inherit"], + ); + assert.deepEqual( + host.locations[0].access_lists.map(({ id }) => id), + [12, 11], + ); + assert.equal(await db()("user").where({ id: 2 }).first(), undefined); + assert.equal(await db()("auth").where({ user_id: 2 }).first(), undefined); + const coverage = await hostCoverage(); + assert.deepEqual( + coverage.items[0].locations.map(({ path: locationPath, anubis }) => [locationPath, anubis]), + [ + ["/private", true], + ["/inherit", true], + ], + ); + await migrateUp(); + assert.equal( + await db()("npmplus_location") + .where({ proxy_host_id: 21 }) + .count() + .first() + .then((row) => Object.values(row)[0]), + 2, + ); + + const restored = knex({ ...db().client.config, connection: { filename: snapshot } }); + try { + assert.equal(await restored.schema.hasTable("npmplus_location"), false); + assert.deepEqual(JSON.parse((await restored("proxy_host").where({ id: 21 }).first()).locations), locations); + assert.ok(await restored("user").where({ id: 2 }).first()); + } finally { + await restored.destroy(); + } +}); diff --git a/docs/setup-npmplus.md b/docs/setup-npmplus.md index efd2a35dba..9f811c1efa 100644 --- a/docs/setup-npmplus.md +++ b/docs/setup-npmplus.md @@ -65,6 +65,20 @@ sudo /opt/npmplus/setup-npmplus.sh Download the new versioned installer and checksum first when moving to a newer release. Rolling `develop` users should similarly download the latest raw script before maintenance. The explicit `sudo bash setup-npmplus.sh --update` form remains available for automation. +### October 16 develop upgrade + +The next develop update moves custom proxy-host locations and their ordered access +lists into database tables and removes previously soft-deleted records. Active +proxy destinations, access-list selections, and Anubis inheritance are preserved. +Use the safe updater so the database and files are snapshotted before migration. +These migrations cannot be undone by selecting an older container image: restore +the matching pre-update snapshot together with its image when rolling back. + +This update also includes the security image refresh and regenerates existing +nginx configurations. It does not create a new release or change the pinned RC9 +installer. Certificate DNS-plugin installation and existing renewal behavior stay +under the fork's reviewed pipeline. + ### September 12 develop upgrade The security fixes, richer attack evidence, and upstream Argon2id integration landed diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f4bfed9170..c075b1d570 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,7 +22,6 @@ function App() { hideProgressBar={true} newestOnTop={true} closeOnClick={true} - rtl={document.dir === "rtl"} closeButton={false} /> diff --git a/frontend/src/api/backend/caseConvert.js b/frontend/src/api/backend/caseConvert.js index c50df1d4d1..eac8df227b 100644 --- a/frontend/src/api/backend/caseConvert.js +++ b/frontend/src/api/backend/caseConvert.js @@ -1,5 +1,4 @@ const separatorPattern = /[-_\s]+(.)?/g; -const firstCharPattern = /^./; const upperBoundaryPattern = /(?=[A-Z])/; const mapKeys = (fn) => { @@ -12,7 +11,5 @@ const mapKeys = (fn) => { }; return walk; }; -export const camelizeKeys = mapKeys((s) => - s.replace(separatorPattern, (_, c) => (c ? c.toUpperCase() : "")).replace(firstCharPattern, (c) => c.toLowerCase()), -); +export const camelizeKeys = mapKeys((s) => s.replace(separatorPattern, (_, c) => c.toUpperCase())); export const decamelizeKeys = mapKeys((s) => s.split(upperBoundaryPattern).join("_").toLowerCase()); diff --git a/frontend/src/api/backend/getToken.js b/frontend/src/api/backend/getToken.js index e77bfd5d9f..fb3da95e9c 100644 --- a/frontend/src/api/backend/getToken.js +++ b/frontend/src/api/backend/getToken.js @@ -7,9 +7,9 @@ export async function getToken(identity, secret) { }); } -export async function verifyTotp(code) { +export async function verifyTotp(data) { return await api.post({ url: "/tokens/totp", - data: { code }, + data, }); } diff --git a/frontend/src/api/backend/getUser.js b/frontend/src/api/backend/getUser.js index eba3a27a40..c7a4815b36 100644 --- a/frontend/src/api/backend/getUser.js +++ b/frontend/src/api/backend/getUser.js @@ -1,9 +1,8 @@ import * as api from "./base"; export async function getUser(id, expand, params = {}) { - const userId = id ? id : "me"; return await api.get({ - url: `/users/${userId}`, + url: `/users/${id}`, params: { expand: expand?.join(","), ...params, diff --git a/frontend/src/api/backend/mfa.js b/frontend/src/api/backend/mfa.js index cca05a487e..6a7cfc137e 100644 --- a/frontend/src/api/backend/mfa.js +++ b/frontend/src/api/backend/mfa.js @@ -6,10 +6,10 @@ export async function getMfaStatus(userId) { }); } -export async function regenerateBackupCodes(userId, code) { +export async function regenerateBackupCodes(userId, data) { return await api.post({ url: `/users/${userId}/mfa/backup-codes`, - data: { code }, + data, }); } diff --git a/frontend/src/api/backend/refreshToken.js b/frontend/src/api/backend/refreshToken.js index c7ed2ce64d..07e3e5dc63 100644 --- a/frontend/src/api/backend/refreshToken.js +++ b/frontend/src/api/backend/refreshToken.js @@ -1,6 +1,6 @@ import * as api from "./base"; -export async function refreshToken(reload = true) { +export async function refreshToken(reload) { return await api.get({ url: "/tokens", reload, diff --git a/frontend/src/api/backend/totp.js b/frontend/src/api/backend/totp.js index ec8ff4795d..bd2c1eec40 100644 --- a/frontend/src/api/backend/totp.js +++ b/frontend/src/api/backend/totp.js @@ -6,16 +6,16 @@ export async function startTotpSetup(userId) { }); } -export async function enableTotp(userId, code) { +export async function enableTotp(userId, data) { return await api.post({ url: `/users/${userId}/mfa/totp/enable`, - data: { code }, + data, }); } -export async function disableTotp(userId, code) { +export async function disableTotp(userId, data) { return await api.post({ url: `/users/${userId}/mfa/totp/disable`, - data: { code }, + data, }); } diff --git a/frontend/src/api/backend/updateAccessList.js b/frontend/src/api/backend/updateAccessList.js index 0cde55ee30..3da20fb935 100644 --- a/frontend/src/api/backend/updateAccessList.js +++ b/frontend/src/api/backend/updateAccessList.js @@ -2,7 +2,7 @@ import * as api from "./base"; export async function updateAccessList(item) { // Remove readonly fields - const { id, createdOn: _, modifiedOn: __, ...data } = item; + const { id, ...data } = item; return await api.put({ url: `/nginx/access-lists/${id}`, diff --git a/frontend/src/api/backend/updateAuth.js b/frontend/src/api/backend/updateAuth.js index 94d76e77ea..ada2af875f 100644 --- a/frontend/src/api/backend/updateAuth.js +++ b/frontend/src/api/backend/updateAuth.js @@ -1,17 +1,12 @@ import * as api from "./base"; export async function updateAuth(userId, newPassword, current) { - const data = { - type: "password", - current, - secret: newPassword, - }; - if (userId === "me") { - data.current = current; - } - return await api.put({ url: `/users/${userId}/auth`, - data, + data: { + type: "password", + current, + secret: newPassword, + }, }); } diff --git a/frontend/src/api/backend/updateDeadHost.js b/frontend/src/api/backend/updateDeadHost.js index 004aebd160..b8b16e1bda 100644 --- a/frontend/src/api/backend/updateDeadHost.js +++ b/frontend/src/api/backend/updateDeadHost.js @@ -2,7 +2,7 @@ import * as api from "./base"; export async function updateDeadHost(item) { // Remove readonly fields - const { id, createdOn: _, modifiedOn: __, ...data } = item; + const { id, ...data } = item; return await api.put({ url: `/nginx/dead-hosts/${id}`, diff --git a/frontend/src/api/backend/updateProxyHost.js b/frontend/src/api/backend/updateProxyHost.js index 95e6a30163..dbd9403f9f 100644 --- a/frontend/src/api/backend/updateProxyHost.js +++ b/frontend/src/api/backend/updateProxyHost.js @@ -2,7 +2,7 @@ import * as api from "./base"; export async function updateProxyHost(item) { // Remove readonly fields - const { id, createdOn: _, modifiedOn: __, ...data } = item; + const { id, ...data } = item; return await api.put({ url: `/nginx/proxy-hosts/${id}`, diff --git a/frontend/src/api/backend/updateRedirectionHost.js b/frontend/src/api/backend/updateRedirectionHost.js index 2e411f596c..b3b620f90f 100644 --- a/frontend/src/api/backend/updateRedirectionHost.js +++ b/frontend/src/api/backend/updateRedirectionHost.js @@ -2,7 +2,7 @@ import * as api from "./base"; export async function updateRedirectionHost(item) { // Remove readonly fields - const { id, createdOn: _, modifiedOn: __, ...data } = item; + const { id, ...data } = item; return await api.put({ url: `/nginx/redirection-hosts/${id}`, diff --git a/frontend/src/api/backend/updateStream.js b/frontend/src/api/backend/updateStream.js index f01a5af658..9a81651edc 100644 --- a/frontend/src/api/backend/updateStream.js +++ b/frontend/src/api/backend/updateStream.js @@ -2,7 +2,7 @@ import * as api from "./base"; export async function updateStream(item) { // Remove readonly fields - const { id, createdOn: _, modifiedOn: __, ...data } = item; + const { id, ...data } = item; return await api.put({ url: `/nginx/streams/${id}`, diff --git a/frontend/src/api/backend/updateUser.js b/frontend/src/api/backend/updateUser.js index a0ed6ebe95..e27adc7fb4 100644 --- a/frontend/src/api/backend/updateUser.js +++ b/frontend/src/api/backend/updateUser.js @@ -2,7 +2,7 @@ import * as api from "./base"; export async function updateUser(item) { // Remove readonly fields - const { id, createdOn: _, modifiedOn: __, ...data } = item; + const { id, ...data } = item; return await api.put({ url: `/users/${id}`, diff --git a/frontend/src/components/EmptyData.jsx b/frontend/src/components/EmptyData.jsx index c9ae05860b..5a7dfa3163 100644 --- a/frontend/src/components/EmptyData.jsx +++ b/frontend/src/components/EmptyData.jsx @@ -9,7 +9,7 @@ function EmptyData({ isFiltered, object, objects, - color = "primary", + color, customAddBtn, permissionSection, permission, diff --git a/frontend/src/components/Form/AccessClientFields.jsx b/frontend/src/components/Form/AccessClientFields.jsx index 6dfec0e3b8..55c14b0fa4 100644 --- a/frontend/src/components/Form/AccessClientFields.jsx +++ b/frontend/src/components/Form/AccessClientFields.jsx @@ -5,11 +5,10 @@ import { useState } from "react"; import { intl, T } from "src/locale"; export function AccessClientFields({ initialValues, name = "clients" }) { - const initial = initialValues || []; const [values, setValues] = useState( - initial.at(-1)?.address?.trim().toLowerCase() === "all" - ? initial - : [...initial, { directive: "deny", address: "all" }], + initialValues.at(-1)?.address?.toLowerCase() === "all" + ? initialValues + : [...initialValues, { directive: "deny", address: "all" }], ); const { setFieldValue } = useFormikContext(); @@ -25,9 +24,6 @@ export function AccessClientFields({ initialValues, name = "clients" }) { const handleRemove = (idx) => { const newValues = values.toSpliced(idx, 1); - if (newValues.length === 1) { - newValues.splice(newValues.length - 1, 0, blankClient); - } setValues(newValues); setFormField(newValues); }; diff --git a/frontend/src/components/Form/AccessFields.jsx b/frontend/src/components/Form/AccessFields.jsx index 7d151747c4..1226773d02 100644 --- a/frontend/src/components/Form/AccessFields.jsx +++ b/frontend/src/components/Form/AccessFields.jsx @@ -1,4 +1,12 @@ -import { IconArrowDown, IconArrowUp, IconLock, IconLockOpen2, IconWorld, IconX } from "@tabler/icons-react"; +import { + IconAlertTriangle, + IconArrowDown, + IconArrowUp, + IconLock, + IconLockOpen2, + IconWorld, + IconX, +} from "@tabler/icons-react"; import { useFormikContext } from "formik"; import { useState } from "react"; import Select, { components } from "react-select"; @@ -26,21 +34,29 @@ const TypeOption = (props) => ( ); -export function AccessFields({ initialAccessListType, location, initialAccessListIds, name, typeFieldName, onChange }) { - const [values, setValues] = useState(initialAccessListIds || []); +export function AccessFields({ + initialAccessListType, + location, + initialAccessListIds, + name, + typeFieldName, + onChange, + authRequest, +}) { + const [values, setValues] = useState(initialAccessListIds); const [aclValue, setAclValue] = useState(initialAccessListType); - const { setFieldValue } = useFormikContext(); + const { values: formValues, setFieldValue } = useFormikContext(); const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); const createDefaultItem = (item) => ({ - value: item.id || 0, + value: item.id, label: item.name, subLabel: intl.formatMessage( { id: "access-list.subtitle" }, { users: item?.items?.length, rules: item?.clients?.length, - date: item?.createdOn ? formatDateTime(item?.createdOn) : "N/A", + date: formatDateTime(item?.createdOn), }, ), meta: item, @@ -72,7 +88,7 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis }; const defaultOptions = data?.map(createDefaultItem) || []; - const valuesSet = new Set(values?.map((item) => item || 0) || []); + const valuesSet = new Set(values); const options = defaultOptions.filter((option) => !valuesSet.has(option.value)); const typeOptions = () => { @@ -89,21 +105,22 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis const applyUpdatedValues = (newValues) => { setValues(newValues); - void setFieldValue(name, newValues); - onChange?.({ npmplusAccessListIds: newValues }); + if (onChange) { + onChange({ npmplusAccessListIds: newValues }); + } else { + void setFieldValue(name, newValues); + } }; const onAccessListChange = (acl, idx) => { - const newValues = values.map((id, i) => (i === idx ? acl.id || 0 : id)); + const newValues = values.map((id, i) => (i === idx ? acl.id : id)); applyUpdatedValues(newValues); }; const handleAdd = () => { const newAccessOption = options[0]; - if (newAccessOption?.meta.id) { - const newValues = [...values, newAccessOption.meta.id]; - applyUpdatedValues(newValues); - } + const newValues = [...values, newAccessOption.meta.id]; + applyUpdatedValues(newValues); }; const handleMoveUp = (idx) => { @@ -133,10 +150,23 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis applyUpdatedValues(newValues); }; + const acls = ( + { + custom: values, + global: formValues.npmplusAccessListType === "custom" ? formValues.npmplusAccessListIds : [], + }[aclValue] ?? [] + ) + .map((id) => data?.find((acl) => acl.id === id)) + .filter(Boolean); + return (
{isLoading ?
: null} - {isError ?
{`${error}`}
: null} + {isError ? ( +
+ +
+ ) : null}
@@ -156,11 +186,13 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis }), }} onChange={(e) => { - if (!e || Array.isArray(e)) return; const value = e.type; setAclValue(value); - void setFieldValue(typeFieldName, value); - onChange?.({ npmplusAccessListType: value }); + if (onChange) { + onChange({ npmplusAccessListType: value }); + } else { + void setFieldValue(typeFieldName, value); + } }} />
@@ -169,12 +201,14 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis {!isLoading && !isError && aclValue === "custom" ? ( <> {values.map((item, idx) => ( -
+
handleChange(idx, "npmplusEnabled", e.target.checked)} /> @@ -277,6 +269,8 @@ export function LocationsFields({ initialValues, name = "locations" }) { className="form-control" placeholder="/path" autoComplete="off" + required + pattern="\S{1,255}" value={item.path} onChange={(e) => handleChange(idx, "path", e.target.value)} /> @@ -331,6 +325,7 @@ export function LocationsFields({ initialValues, name = "locations" }) { type="text" className="form-control" required={item.forwardScheme !== "empty"} + pattern="\S{0,255}" placeholder="eg: 10.0.0.1/path/" value={item.forwardHost} onChange={(e) => handleChange(idx, "forwardHost", e.target.value)} @@ -349,7 +344,7 @@ export function LocationsFields({ initialValues, name = "locations" }) { max={65535} className="form-control" placeholder="eg: 8081" - value={item.forwardPort} + value={item.forwardPort || ""} onChange={(e) => handleChange(idx, "forwardPort", e.target.value)} />
@@ -602,8 +597,9 @@ export function LocationsFields({ initialValues, name = "locations" }) { type="text" className={`form-control ${item.npmplusAuthRequestUpstream && !upstreamUrlPattern.test(item.npmplusAuthRequestUpstream) ? "is-invalid" : ""}`} placeholder="keep empty to reuse env value" - pattern="^https?://([^/:]+|\[[a-fA-F0-9:]+\]):[0-9]+$" - value={item.npmplusAuthRequestUpstream || ""} + maxLength={255} + pattern="^https?://(([^\s\/:]+|\[[a-fA-F0-9:]+\]):[0-9]+|cu_[^\s\/:]+)$" + value={item.npmplusAuthRequestUpstream} onChange={(e) => handleChange( idx, @@ -630,12 +626,11 @@ export function LocationsFields({ initialValues, name = "locations" }) { handleAccessFieldsChange(idx, changes)} + authRequest={item.npmplusAuthRequest} />
diff --git a/frontend/src/components/Form/SSLCertificateField.jsx b/frontend/src/components/Form/SSLCertificateField.jsx index 18b73ae292..33eb1dd592 100644 --- a/frontend/src/components/Form/SSLCertificateField.jsx +++ b/frontend/src/components/Form/SSLCertificateField.jsx @@ -17,18 +17,16 @@ const Option = (props) => ( ); export function SSLCertificateField({ - name = "certificateId", - label = "ssl-certificate", + name, + label, id = "certificateId", - required, allowNew, forHttp = true, mtlsName = "npmplusMtlsCertificateId", mtlsLabel = "mtls-certificate", }) { const { isLoading, isError, error, data } = useCertificates(); - const { values, setFieldValue } = useFormikContext(); - const v = values || {}; + const { values: v, setFieldValue } = useFormikContext(); const handleChange = (newValue, _actionMeta) => { void setFieldValue(name, newValue?.value); @@ -70,7 +68,7 @@ export function SSLCertificateField({ .map((cert) => ({ value: cert.id, label: cert.niceName, - subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`, + subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: formatDateTime(cert.expiresOn) })}`, icon: , })) || []; @@ -80,7 +78,7 @@ export function SSLCertificateField({ .map((cert) => ({ value: cert.id, label: cert.niceName, - subLabel: `${cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`, + subLabel: `${cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: formatDateTime(cert.expiresOn) })}`, icon: , })) || []; @@ -95,16 +93,14 @@ export function SSLCertificateField({ } // Prepend the None option - if (!required) { - options?.unshift({ - value: 0, - label: intl.formatMessage({ id: "certificate.none.title" }), - subLabel: forHttp - ? intl.formatMessage({ id: "certificate.none.subtitle.for-http" }) - : intl.formatMessage({ id: "certificate.none.subtitle" }), - icon: , - }); - } + options?.unshift({ + value: 0, + label: intl.formatMessage({ id: "certificate.none.title" }), + subLabel: forHttp + ? intl.formatMessage({ id: "certificate.none.subtitle.for-http" }) + : intl.formatMessage({ id: "certificate.none.subtitle" }), + icon: , + }); mtlsOptions?.unshift({ value: 0, @@ -116,7 +112,7 @@ export function SSLCertificateField({ return ( <> - {({ field, form }) => ( + {({ field }) => (
)}
- {({ field, form }) => ( + {({ field }) => (
)}
diff --git a/frontend/src/components/Form/SSLOptionsFields.jsx b/frontend/src/components/Form/SSLOptionsFields.jsx index cd597d51a7..0bc0405642 100644 --- a/frontend/src/components/Form/SSLOptionsFields.jsx +++ b/frontend/src/components/Form/SSLOptionsFields.jsx @@ -1,15 +1,13 @@ import cn from "clsx"; import { Field, useFormikContext } from "formik"; -import { useEffect } from "react"; -import { DNSProviderFields, DomainNamesField } from "src/components"; +import { DNSProviderFields } from "src/components"; import { T } from "src/locale"; -export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames, color = "bg-cyan" }) { - const { values, setFieldValue } = useFormikContext(); - const v = values || {}; +export function SSLOptionsFields({ forProxy, color }) { + const { values: v, setFieldValue } = useFormikContext(); const newCertificate = v?.certificateId === "new"; - const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0); + const hasCertificate = newCertificate || v?.certificateId > 0; const { sslForced, npmplusHttp3Support, @@ -19,13 +17,12 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain npmplusReuseKey: reuseKey, npmplusMtlsCertificateId, npmplusMtlsVerifyClientOptional, + npmplusMtlsBypassAuthRequest, } = v; - useEffect(() => { - if (forceDNSForNew && newCertificate && !dnsChallenge) { - void setFieldValue("npmplusDnsChallenge", true); - } - }); + const mtlsBypassAuthRequestPossible = + npmplusMtlsVerifyClientOptional && + [v, ...(v.locations ?? [])].some(({ npmplusAuthRequest = "none" }) => npmplusAuthRequest !== "none"); const handleToggleChange = (e, fieldName) => { void setFieldValue(fieldName, e.target.checked); @@ -56,7 +53,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain handleToggleChange(e, field.name)} disabled={!hasCertificate} /> @@ -75,7 +72,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain handleToggleChange(e, field.name)} disabled={!hasCertificate} /> @@ -96,7 +93,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain handleToggleChange(e, field.name)} disabled={!hasCertificate || !sslForced} /> @@ -115,7 +112,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain handleToggleChange(e, field.name)} disabled={!hasCertificate || !hstsEnabled} /> @@ -134,14 +131,14 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain return (
-
+
{({ field }) => (
+ {forProxy ? ( +
+ + {({ field }) => ( + + )} + +
+ ) : null}
- {forHttp ? getHttpOptions() : null} + {getHttpOptions()} {newCertificate ? (
@@ -182,8 +204,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain handleToggleChange(e, field.name)} /> @@ -194,7 +215,6 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain )}
- {requireDomainNames ? : null} {dnsChallenge ? : null}
) : null} diff --git a/frontend/src/components/LocalePicker.jsx b/frontend/src/components/LocalePicker.jsx index 52ea36fd68..33e9357fc4 100644 --- a/frontend/src/components/LocalePicker.jsx +++ b/frontend/src/components/LocalePicker.jsx @@ -3,7 +3,7 @@ import { Flag } from "src/components"; import { changeLocale, getFlagCodeForLocale, localeOptions } from "src/locale"; import localeList from "translations/lang-list.json" with { type: "json" }; -function LocalePicker({ menuAlign = "start" }) { +function LocalePicker({ menuAlign }) { return (
-
- {item.items?.map((subitem, idx) => ( - - - - - - ))} -
- - + +
+ {item.items?.map((subitem, idx) => ( + + {React.createElement(subitem.icon, { width: 18 })} + + + ))} +
+ ); export function SiteMenu() { const { data: user } = useUser("me"); + const { pathname } = useLocation(); const closeMenu = () => setTimeout(() => { @@ -169,26 +178,38 @@ export function SiteMenu() { }, 300); return ( -
+
diff --git a/frontend/src/modals/ChangePasswordModal.jsx b/frontend/src/modals/ChangePasswordModal.jsx index 03317678e7..d150aa6d2c 100644 --- a/frontend/src/modals/ChangePasswordModal.jsx +++ b/frontend/src/modals/ChangePasswordModal.jsx @@ -59,7 +59,7 @@ const ChangePasswordModal = EasyModal.create(({ id, visible, remove }) => { - setError(null)} dismissible> + setError(null)} dismissible> {error} diff --git a/frontend/src/modals/CustomCertificateModal.jsx b/frontend/src/modals/CustomCertificateModal.jsx index 91147ed44f..b21b5c5891 100644 --- a/frontend/src/modals/CustomCertificateModal.jsx +++ b/frontend/src/modals/CustomCertificateModal.jsx @@ -39,7 +39,7 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove, cert, provid // Validate await validateCertificate(formData); - if (isEdit && cert) { + if (isEdit) { // Upload the certificates to the exiting certificate await uploadCertificate(cert.id, formData); } else { @@ -87,12 +87,7 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove, cert, provid - setErrorMsg(null)} - dismissible - > + setErrorMsg(null)} dismissible> {errorMsg}
@@ -120,9 +115,7 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove, cert, provid {form.errors.niceName ? (
- {form.errors.niceName && form.touched.niceName - ? form.errors.niceName - : null} + {form.touched.niceName ? form.errors.niceName : null}
) : null}
@@ -141,22 +134,9 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove, cert, provid autoComplete="off" className="form-control" onChange={(event) => { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); + form.setFieldValue(field.name, event.currentTarget.files[0]); }} /> - - {form.errors.certificate ? ( -
- {form.errors.certificate && form.touched.certificate - ? form.errors.certificate - : null} -
- ) : null}
)} @@ -176,20 +156,10 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove, cert, provid onChange={(event) => { form.setFieldValue( field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, + event.currentTarget.files[0], ); }} /> - - {form.errors.certificateKey ? ( -
- {form.errors.certificateKey && form.touched.certificateKey - ? form.errors.certificateKey - : null} -
- ) : null}
)} diff --git a/frontend/src/modals/DNSCertificateModal.jsx b/frontend/src/modals/DNSCertificateModal.jsx index 8dad411c11..097750c689 100644 --- a/frontend/src/modals/DNSCertificateModal.jsx +++ b/frontend/src/modals/DNSCertificateModal.jsx @@ -68,12 +68,7 @@ const DNSCertificateModal = EasyModal.create(({ visible, remove }) => { - setErrorMsg(null)} - dismissible - > + setErrorMsg(null)} dismissible> {errorMsg}
diff --git a/frontend/src/modals/DeadHostModal.jsx b/frontend/src/modals/DeadHostModal.jsx index 2797b4fda6..d4a59cf3a5 100644 --- a/frontend/src/modals/DeadHostModal.jsx +++ b/frontend/src/modals/DeadHostModal.jsx @@ -22,7 +22,7 @@ const showDeadHostModal = (id) => { }; const DeadHostModal = EasyModal.create(({ id, visible, remove }) => { - const { data, isLoading, error } = useDeadHost(id); + const { data, isPending, error } = useDeadHost(id); const { mutate: setDeadHost } = useSetDeadHost(); const { data: allDeadHosts } = useDeadHosts(); const suggestions = useDirectorySuggestions(allDeadHosts); @@ -34,7 +34,7 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }) => { setIsSubmitting(true); setErrorMsg(null); - const { ...payload } = { + const payload = { id: id === "new" ? undefined : id, ...values, }; @@ -66,13 +66,13 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }) => { return ( - {!isLoading && error && ( + {error && ( - {error?.message || "Unknown error"} + {error?.message} )} - {isLoading && } - {!isLoading && data && ( + {isPending && } + {data && ( { npmplusHttp3Support: data?.npmplusHttp3Support, hstsEnabled: data?.hstsEnabled, hstsSubdomains: data?.hstsSubdomains, - npmplusDirectory: data?.npmplusDirectory || "", - npmplusMtlsCertificateId: data?.npmplusMtlsCertificateId || 0, - npmplusMtlsVerifyClientOptional: data?.npmplusMtlsVerifyClientOptional || false, + npmplusDirectory: data?.npmplusDirectory, + npmplusMtlsCertificateId: data?.npmplusMtlsCertificateId, + npmplusMtlsVerifyClientOptional: data?.npmplusMtlsVerifyClientOptional, }} onSubmit={onSubmit} > @@ -96,12 +96,7 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }) => { - setErrorMsg(null)} - dismissible - > + setErrorMsg(null)} dismissible> {errorMsg}
diff --git a/frontend/src/modals/DeleteConfirmModal.jsx b/frontend/src/modals/DeleteConfirmModal.jsx index 581a8fd8f7..e7cd05a21e 100644 --- a/frontend/src/modals/DeleteConfirmModal.jsx +++ b/frontend/src/modals/DeleteConfirmModal.jsx @@ -24,7 +24,7 @@ const DeleteConfirmModal = EasyModal.create( await onConfirm(); remove(); // invalidate caches as requested - await Promise.all((invalidations ?? []).map((inv) => queryClient.invalidateQueries({ queryKey: inv }))); + await Promise.all(invalidations.map((inv) => queryClient.invalidateQueries({ queryKey: inv }))); } catch (err) { setError(); } @@ -37,7 +37,7 @@ const DeleteConfirmModal = EasyModal.create( {tTitle ? : title} - setError(null)} dismissible> + setError(null)} dismissible> {error}
diff --git a/frontend/src/modals/EventDetailsModal.jsx b/frontend/src/modals/EventDetailsModal.jsx index eaaea5efcb..1ac4c8e8dc 100644 --- a/frontend/src/modals/EventDetailsModal.jsx +++ b/frontend/src/modals/EventDetailsModal.jsx @@ -10,17 +10,17 @@ const showEventDetailsModal = (id) => { }; const EventDetailsModal = EasyModal.create(({ id, visible, remove }) => { - const { data, isLoading, error } = useAuditLog(id); + const { data, isPending, error } = useAuditLog(id); return ( - {!isLoading && error && ( + {error && ( - {error?.message || "Unknown error"} + {error?.message} )} - {isLoading && } - {!isLoading && data && ( + {isPending && } + {data && ( <> @@ -30,7 +30,7 @@ const EventDetailsModal = EasyModal.create(({ id, visible, remove }) => {
- +
diff --git a/frontend/src/modals/HTTPCertificateModal.jsx b/frontend/src/modals/HTTPCertificateModal.jsx index 7c5d21a4cf..5f1cbe07f1 100644 --- a/frontend/src/modals/HTTPCertificateModal.jsx +++ b/frontend/src/modals/HTTPCertificateModal.jsx @@ -66,47 +66,43 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }) => { const toggleClasses = "form-check-input"; const toggleEnabled = cn(toggleClasses, "bg-cyan"); - const parseTestResults = () => { - if (!testResults) return null; + const parseTestResults = () => ( + <> + {testResults.map((testResult) => { + const { domain, status } = testResult; + let messageComponent; - return ( - <> - {testResults.map((testResult) => { - const { domain, status } = testResult; - let messageComponent; - - switch (status) { - case "ok": - messageComponent = ; - break; - case "no-host": - messageComponent = ; - break; - case "failed": - messageComponent = ; - break; - case "404": - messageComponent = ; - break; - case "wrong-data": - messageComponent = ; - break; - default: { - const code = status.slice(6); - messageComponent = ; - break; - } + switch (status) { + case "ok": + messageComponent = ; + break; + case "no-host": + messageComponent = ; + break; + case "failed": + messageComponent = ; + break; + case "404": + messageComponent = ; + break; + case "wrong-data": + messageComponent = ; + break; + default: { + const code = status.slice(6); + messageComponent = ; + break; } + } - return ( -

- {domain}: {messageComponent} -

- ); - })} - - ); - }; + return ( +

+ {domain}: {messageComponent} +

+ ); + })} + + ); return ( @@ -126,12 +122,7 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }) => { - setErrorMsg(null)} - dismissible - > + setErrorMsg(null)} dismissible> {errorMsg}
diff --git a/frontend/src/modals/MfaModal.jsx b/frontend/src/modals/MfaModal.jsx index 7c4c768c08..27a76e8060 100644 --- a/frontend/src/modals/MfaModal.jsx +++ b/frontend/src/modals/MfaModal.jsx @@ -31,7 +31,7 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { setBackupCodesRemaining(status.backupCodesRemaining); setStep("status"); } catch (err) { - setError(err.message || "Failed to load MFA status"); + setError(err.message); setStep("status"); } }, [id]); @@ -48,7 +48,7 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { setSetupData(data); setStep("setup"); } catch (err) { - setError(err.message || "Failed to start TOTP setup"); + setError(err.message); } setIsSubmitting(false); }; @@ -57,7 +57,7 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { setError(null); setIsSubmitting(true); try { - const result = await enableTotp(id, values.code); + const result = await enableTotp(id, values); if (result.backupCodes) { setBackupCodes(result.backupCodes); setStep("backup"); @@ -65,7 +65,7 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { await loadStatus(); } } catch (err) { - setError(err.message || "Failed to enable TOTP"); + setError(err.message); } setIsSubmitting(false); }; @@ -74,11 +74,11 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { setError(null); setIsSubmitting(true); try { - await disableTotp(id, values.code); + await disableTotp(id, values); setIsEnabled(false); setStep("status"); } catch (err) { - setError(err.message || "Failed to disable TOTP"); + setError(err.message); } setIsSubmitting(false); }; @@ -87,11 +87,11 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { setError(null); setIsSubmitting(true); try { - const result = await regenerateBackupCodes(id, values.code); + const result = await regenerateBackupCodes(id, values); setBackupCodes(result.backupCodes); setStep("backup"); } catch (err) { - setError(err.message || "Failed to regenerate backup codes"); + setError(err.message); } setIsSubmitting(false); }; @@ -149,7 +149,7 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { ); } - if (step === "setup" && setupData) { + if (step === "setup") { return (
@@ -411,7 +411,7 @@ const MfaModal = EasyModal.create(({ id, visible, remove }) => { - setError(null)} dismissible> + setError(null)} dismissible> {error} {renderContent()} diff --git a/frontend/src/modals/PermissionsModal.jsx b/frontend/src/modals/PermissionsModal.jsx index 98f72a16fd..df92349cb7 100644 --- a/frontend/src/modals/PermissionsModal.jsx +++ b/frontend/src/modals/PermissionsModal.jsx @@ -18,7 +18,7 @@ const showPermissionsModal = (id) => { const PermissionsModal = EasyModal.create(({ id, visible, remove }) => { const queryClient = useQueryClient(); const [errorMsg, setErrorMsg] = useState(null); - const { data, isLoading, error } = useUser(id); + const { data, isPending, error } = useUser(id); const [isSubmitting, setIsSubmitting] = useState(false); const onSubmit = async (values, { setSubmitting }) => { @@ -134,13 +134,13 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }) => { return ( - {!isLoading && error && ( + {error && ( - {error?.message || "Unknown error"} + {error?.message} )} - {isLoading && } - {!isLoading && data && ( + {isPending && } + {data && ( { - setErrorMsg(null)} - dismissible - > + setErrorMsg(null)} dismissible> {errorMsg}
diff --git a/frontend/src/modals/ProxyHostModal.jsx b/frontend/src/modals/ProxyHostModal.jsx index 8fa1cdcb70..0f72b26c01 100644 --- a/frontend/src/modals/ProxyHostModal.jsx +++ b/frontend/src/modals/ProxyHostModal.jsx @@ -16,22 +16,25 @@ import { SSLCertificateField, SSLOptionsFields, } from "src/components"; -import { useDirectorySuggestions, useProxyHost, useProxyHosts, useSetProxyHost, useUser } from "src/hooks"; +import { useDirectorySuggestions, useProxyHost, useProxyHosts, useSetProxyHost } from "src/hooks"; import { intl, T } from "src/locale"; import EasyModal from "src/modules/easyModal"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; import { showTabOfInvalid, validateNumber, validateUpstreamUrl } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; -const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove }) => { - const { data: currentUser, isLoading: userIsLoading, error: userError } = useUser("me"); - const { data, isLoading, error } = useProxyHost(id); +const ProxyHostModal = EasyModal.create(({ id, isClone, visible, remove }) => { + const { data, isPending, error } = useProxyHost(id); const { data: allProxyHosts } = useProxyHosts(); const suggestions = useDirectorySuggestions(allProxyHosts); const { mutate: setProxyHost } = useSetProxyHost(); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [advVisible, setAdvVisible] = useState(false); + const initialLocations = data?.locations.map(({ accessLists, ...location }) => ({ + ...location, + npmplusAccessListIds: accessLists.map((accessList) => accessList.id), + })); const onSubmit = (values, { setSubmitting }) => { if (isSubmitting) return; @@ -40,24 +43,17 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove // Set the unrestricted acls here (remove any data in their acl lists) const globalType = values.npmplusAccessListType; - let globalAclIds = values.npmplusAccessListIds || []; + let globalAclIds = values.npmplusAccessListIds; if (globalType === "public") { globalAclIds = []; } - const locations = (values.locations || []).map((loc) => { - const newLoc = { ...loc }; - if (loc.npmplusAccessListType === "global" || loc.npmplusAccessListType === "public") { - newLoc.npmplusAccessListIds = []; - } - return newLoc; - }); - const { ...payload } = { + const payload = { id: id === "new" || isClone ? undefined : id, ...values, npmplusAccessListIds: globalAclIds, - locations, - forwardPort: values.forwardPort || null, + forwardPort: values.forwardPort || 0, + locations: values.locations.map((location) => ({ ...location, forwardPort: location.forwardPort || 0 })), }; setProxyHost(payload, { @@ -87,45 +83,46 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove return ( - {!isLoading && (error || userError) && ( + {error && ( - {error?.message || userError?.message || "Unknown error"} + {error?.message} )} - {isLoading || (userIsLoading && )} - {!isLoading && !userIsLoading && data && currentUser && ( + {isPending && } + {data && ( accessList.id), + npmplusAccessListType: data?.npmplusAccessListType, // Locations tab - locations: data?.locations || [], + locations: initialLocations, // SSL tab - certificateId: data?.certificateId || 0, - sslForced: data?.sslForced || false, - npmplusHttp3Support: data?.npmplusHttp3Support || false, - hstsEnabled: data?.hstsEnabled || false, - hstsSubdomains: data?.hstsSubdomains || false, + certificateId: data?.certificateId, + sslForced: data?.sslForced, + npmplusHttp3Support: data?.npmplusHttp3Support, + hstsEnabled: data?.hstsEnabled, + hstsSubdomains: data?.hstsSubdomains, // Advanced tab - advancedConfig: data?.advancedConfig || "", - npmplusLocationConfig: data?.npmplusLocationConfig || "", - npmplusDirectory: data?.npmplusDirectory || "", - npmplusMtlsCertificateId: data?.npmplusMtlsCertificateId || 0, - npmplusMtlsVerifyClientOptional: data?.npmplusMtlsVerifyClientOptional || false, - npmplusNoindex: data?.npmplusNoindex || false, - npmplusCrowdsecAppsec: data?.npmplusCrowdsecAppsec || false, - npmplusProxyResponseBuffering: data?.npmplusProxyResponseBuffering || false, - npmplusProxyRequestBuffering: data?.npmplusProxyRequestBuffering || false, - npmplusUpstreamCompression: data?.npmplusUpstreamCompression || false, - npmplusFancyindex: data?.npmplusFancyindex || false, - npmplusXFrameOptions: data?.npmplusXFrameOptions || "SAMEORIGIN", - npmplusAuthRequest: data?.npmplusAuthRequest || "none", - npmplusAuthRequestUpstream: data?.npmplusAuthRequestUpstream || "", + advancedConfig: data?.advancedConfig, + npmplusLocationConfig: data?.npmplusLocationConfig, + npmplusDirectory: data?.npmplusDirectory, + npmplusMtlsCertificateId: data?.npmplusMtlsCertificateId, + npmplusMtlsVerifyClientOptional: data?.npmplusMtlsVerifyClientOptional, + npmplusMtlsBypassAuthRequest: data?.npmplusMtlsBypassAuthRequest, + npmplusNoindex: data?.npmplusNoindex, + npmplusCrowdsecAppsec: data?.npmplusCrowdsecAppsec, + npmplusProxyResponseBuffering: data?.npmplusProxyResponseBuffering, + npmplusProxyRequestBuffering: data?.npmplusProxyRequestBuffering, + npmplusUpstreamCompression: data?.npmplusUpstreamCompression, + npmplusFancyindex: data?.npmplusFancyindex, + npmplusXFrameOptions: data?.npmplusXFrameOptions, + npmplusAuthRequest: data?.npmplusAuthRequest, + npmplusAuthRequestUpstream: data?.npmplusAuthRequestUpstream, }} onSubmit={onSubmit} > @@ -140,12 +137,7 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove - setErrorMsg(null)} - dismissible - > + setErrorMsg(null)} dismissible> {errorMsg}
@@ -271,19 +263,12 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove - - {form.errors.forwardHost ? ( -
- {form.errors.forwardHost && - form.touched.forwardHost - ? form.errors.forwardHost - : null} -
- ) : null}
)} @@ -299,7 +284,7 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove id="forwardPort" type="text" inputMode="numeric" - pattern="[0-9]*" + pattern="[1-9][0-9]*" className={`form-control ${form.errors.forwardPort && form.touched.forwardPort ? "is-invalid" : ""}`} placeholder="eg: 8081" {...field} @@ -307,8 +292,7 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove {form.errors.forwardPort ? (
- {form.errors.forwardPort && - form.touched.forwardPort + {form.touched.forwardPort ? form.errors.forwardPort : null}
@@ -591,12 +575,21 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove - {({ field }) => ( + {({ field, form }) => (