From 9aa7fd5722320cde499a0dfe3da7a642c06285cb Mon Sep 17 00:00:00 2001 From: addielarue Date: Sat, 25 Jul 2026 22:55:58 +1000 Subject: [PATCH 01/55] fix(stream): bracket IPv6 forward host for valid nginx upstream A Stream Host with an IPv6 address as the Forward Host was accepted and saved but never activated. The generated stream config rendered `proxy_pass {{ forwarding_host }}:{{ forwarding_port }}` as e.g. `fe80::528:3c87:e7bb:ab08:25`, which nginx rejects with "invalid port in upstream" because an IPv6 literal must be wrapped in square brackets before the port is appended (`[fe80::...]:25`). Normalize the stream forward host in generateConfig (alongside the existing per-host-type data massaging) using net.isIPv6(), so IPv6 hosts render as `[address]:port` while IPv4 addresses and hostnames are emitted unchanged. The mutation is applied to the deep-copied render object, so persisted and audit data are unaffected. Fixes #5740 --- backend/internal/nginx.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..bd405d802a 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -1,4 +1,5 @@ import fs from "node:fs"; +import net from "node:net"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import _ from "lodash"; @@ -221,6 +222,14 @@ const internalNginx = { host.forward_scheme = "$scheme"; } + // A stream forwarding to an IPv6 literal must have the address wrapped in + // square brackets before nginx appends ":". Without the brackets nginx + // reads the trailing ":" as part of the address and rejects the upstream + // ("invalid port in upstream"), so the stream saves but never activates (#5740). + if (nice_host_type === "stream" && net.isIPv6(host.forwarding_host)) { + host.forwarding_host = `[${host.forwarding_host}]`; + } + if (host.locations) { //logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2)); origLocations = [].concat(host.locations); From 4a0f21228941aa06baacda3777a24bb4477608ba Mon Sep 17 00:00:00 2001 From: "Dimas R. Wisnu" Date: Fri, 7 Aug 2026 16:40:32 +0700 Subject: [PATCH 02/55] feat: per-path access lists, host logs modal, PostgreSQL support - Per-path access lists: assign different access lists to individual locations on the same proxy host - Host logs modal: view access/error logs from proxy host dropdown - PostgreSQL JSON containment query (@>) for location regeneration - Locale keys: action.logs, column.error --- backend/internal/access-list.js | 101 +++++++++++ backend/internal/proxy-host.js | 163 +++++++++++------- backend/routes/nginx/proxy_hosts.js | 67 +++++++ .../schema/components/proxy-host-object.json | 6 +- frontend/src/api/backend/getProxyHostLogs.ts | 8 + frontend/src/api/backend/index.ts | 1 + frontend/src/api/backend/models.ts | 2 + frontend/src/components/Form/AccessField.tsx | 6 +- .../src/components/Form/LocationsFields.tsx | 14 ++ frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useProxyHostLogs.ts | 12 ++ frontend/src/locale/src/en.json | 6 + frontend/src/modals/HostLogsModal.tsx | 93 ++++++++++ frontend/src/modals/index.ts | 1 + frontend/src/pages/Nginx/ProxyHosts/Table.tsx | 28 ++- .../pages/Nginx/ProxyHosts/TableWrapper.tsx | 3 +- 16 files changed, 434 insertions(+), 78 deletions(-) create mode 100644 frontend/src/api/backend/getProxyHostLogs.ts create mode 100644 frontend/src/hooks/useProxyHostLogs.ts create mode 100644 frontend/src/modals/HostLogsModal.tsx diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 88bf9df523..413ac067af 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -2,7 +2,9 @@ import fs from "node:fs"; import batchflow from "batchflow"; import _ from "lodash"; import errs from "../lib/error.js"; +import { isMysql, isPostgres } from "../lib/config.js"; import utils from "../lib/utils.js"; +import db from "../db.js"; import { access as logger } from "../logger.js"; import accessListModel from "../models/access_list.js"; import accessListAuthModel from "../models/access_list_auth.js"; @@ -15,6 +17,36 @@ const omissions = () => { return ["is_deleted"]; }; +/** + * Find proxy hosts that reference an access list in their locations JSON. + * + * @param {Integer} accessListId + * @returns {Promise} + */ +const getProxyHostsUsingAccessListInLocations = async (accessListId) => { + let result; + if (isMysql()) { + const searchObj = JSON.stringify([{ access_list_id: accessListId }]); + result = await db().raw( + `SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)`, + [searchObj, "$"], + ); + } else if (isPostgres()) { + result = await db().raw( + `SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb`, + [JSON.stringify([{ access_list_id: accessListId }])], + ); + } else { + result = await db().raw( + `SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?`, + [`%"access_list_id":${accessListId}%`], + ); + } + // knex raw() returns [rows, metadata] for MySQL + const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; + return rows || []; +}; + const internalAccessList = { /** * @param {Access} access @@ -187,6 +219,44 @@ const internalAccessList = { if (Number.parseInt(freshRow.proxy_host_count, 10)) { await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts); } + + // Also regenerate configs for proxy hosts that reference this access list in their locations + const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id); + if (locationHostRows && locationHostRows.length) { + const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => { + // Exclude hosts already regenerated above + return !freshRow.proxy_hosts || !freshRow.proxy_hosts.find((h) => h.id === id); + }); + if (locationHostIds.length) { + const locationHosts = await proxyHostModel.query() + .where("is_deleted", 0) + .whereIn("id", locationHostIds) + .allowGraph(proxyHostModel.defaultAllowGraph) + .withGraphFetched("[owner, certificate, access_list.[clients,items]]"); + for (const host of locationHosts) { + // Fetch access lists for locations + if (host.locations && host.locations.length) { + for (let i = 0; i < host.locations.length; i++) { + const loc = host.locations[i]; + if (loc.access_list_id && loc.access_list_id > 0) { + const locAccessList = await accessListModel + .query() + .allowGraph("[clients,items]") + .where("is_deleted", 0) + .andWhere("id", loc.access_list_id) + .withGraphFetched("[clients,items]") + .first(); + if (locAccessList) { + host.locations[i].access_list = locAccessList; + } + } + } + } + } + await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts); + } + } + await internalNginx.reload(); return internalAccessList.maskItems(freshRow); }, @@ -291,6 +361,37 @@ const internalAccessList = { await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); } + // Also handle proxy hosts that reference this access list in their locations JSON + const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id); + if (locationHostRows && locationHostRows.length) { + const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => { + return !row.proxy_hosts || !row.proxy_hosts.find((h) => h.id === id); + }); + if (locationHostIds.length) { + // Clear the access_list_id in locations JSON for these hosts + for (const hostId of locationHostIds) { + const host = await proxyHostModel.query().where("id", hostId).first(); + if (host && host.locations) { + const updatedLocations = host.locations.map((loc) => { + if (loc.access_list_id === row.id) { + return { ...loc, access_list_id: 0 }; + } + return loc; + }); + await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations }); + } + } + + // Re-fetch and regenerate configs + const locationHosts = await proxyHostModel.query() + .where("is_deleted", 0) + .whereIn("id", locationHostIds) + .allowGraph(proxyHostModel.defaultExpand) + .withGraphFetched("[owner, certificate, access_list.[clients,items]]"); + await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts); + } + } + await internalNginx.reload(); // delete the htpasswd file diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 2c159d48ad..96cfab480e 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -2,6 +2,7 @@ import _ from "lodash"; import errs from "../lib/error.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import utils from "../lib/utils.js"; +import accessListModel from "../models/access_list.js"; import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; @@ -12,6 +13,33 @@ const omissions = () => { return ["is_deleted", "owner.is_deleted"]; }; +/** + * Fetches access lists for each location that has its own access_list_id. + * Attaches the expanded access_list object (with clients and items) to each location. + * + * @param {Object} host + * @returns {Promise} + */ +const fetchLocationAccessLists = async (host) => { + if (!host.locations || !host.locations.length) { + return; + } + for (let i = 0; i < host.locations.length; i++) { + const loc = host.locations[i]; + if (loc.access_list_id && loc.access_list_id > 0) { + const accessList = await accessListModel + .query() + .where("is_deleted", 0) + .andWhere("id", loc.access_list_id) + .withGraphFetched("[clients,items]") + .first(); + if (accessList) { + host.locations[i].access_list = accessList; + } + } + } +}; + const internalProxyHost = { /** * @param {Access} access @@ -83,28 +111,29 @@ const internalProxyHost = { expand: ["certificate", "owner", "access_list.[clients,items]"], }); }) - .then((row) => { - // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => { + .then(async (row) => { + await fetchLocationAccessLists(row); + // Configure nginx + return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => { + return row; + }); + }) + .then((row) => { + // Audit log + thisData.meta = _.assign({}, thisData.meta || {}, row.meta); + + // Add to audit log + return internalAuditLog + .add(access, { + action: "created", + object_type: "proxy-host", + object_id: row.id, + meta: thisData, + }) + .then(() => { return row; }); - }) - .then((row) => { - // Audit log - thisData.meta = _.assign({}, thisData.meta || {}, row.meta); - - // Add to audit log - return internalAuditLog - .add(access, { - action: "created", - object_type: "proxy-host", - object_id: row.id, - meta: thisData, - }) - .then(() => { - return row; - }); - }); + }); }, /** @@ -202,24 +231,25 @@ const internalProxyHost = { }); }); }) - .then(() => { - return internalProxyHost - .get(access, { - id: thisData.id, - expand: ["owner", "certificate", "access_list.[clients,items]"], - }) - .then((row) => { - if (!row.enabled) { - // No need to add nginx config if host is disabled - return row; - } - // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { - row.meta = new_meta; - return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); - }); + .then(() => { + return internalProxyHost + .get(access, { + id: thisData.id, + expand: ["owner", "certificate", "access_list.[clients,items]"], + }) + .then(async (row) => { + if (!row.enabled) { + // No need to add nginx config if host is disabled + return row; + } + await fetchLocationAccessLists(row); + // Configure nginx + return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { + row.meta = new_meta; + return _.omit(internalHost.cleanRowCertificateMeta(row), omissions()); }); - }); + }); + }); }, /** @@ -326,39 +356,38 @@ const internalProxyHost = { expand: ["certificate", "owner", "access_list"], }); }) - .then((row) => { - if (!row?.id) { - throw new errs.ItemNotFoundError(data.id); - } - if (row.enabled) { - throw new errs.ValidationError("Host is already enabled"); - } + .then(async (row) => { + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + if (row.enabled) { + throw new errs.ValidationError("Host is already enabled"); + } + + row.enabled = 1; + + await proxyHostModel + .query() + .where("id", row.id) + .patch({ + enabled: 1, + }); - row.enabled = 1; + await fetchLocationAccessLists(row); - return proxyHostModel - .query() - .where("id", row.id) - .patch({ - enabled: 1, - }) - .then(() => { - // Configure nginx - return internalNginx.configure(proxyHostModel, "proxy_host", row); - }) - .then(() => { - // Add to audit log - return internalAuditLog.add(access, { - action: "enabled", - object_type: "proxy-host", - object_id: row.id, - meta: _.omit(row, omissions()), - }); - }); - }) - .then(() => { - return true; + // Configure nginx + await internalNginx.configure(proxyHostModel, "proxy_host", row); + + // Add to audit log + await internalAuditLog.add(access, { + action: "enabled", + object_type: "proxy-host", + object_id: row.id, + meta: _.omit(row, omissions()), }); + + return true; + }); }, /** diff --git a/backend/routes/nginx/proxy_hosts.js b/backend/routes/nginx/proxy_hosts.js index 7045a195cc..ef62cf835a 100644 --- a/backend/routes/nginx/proxy_hosts.js +++ b/backend/routes/nginx/proxy_hosts.js @@ -1,4 +1,5 @@ import express from "express"; +import fs from "node:fs"; import internalProxyHost from "../../internal/proxy-host.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; @@ -206,4 +207,70 @@ router } }); +/** + * Proxy-host logs + * + * /api/nginx/proxy-hosts/123/logs + */ +router + .route("/:host_id/logs") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/nginx/proxy-hosts/123/logs + * + * Retrieve logs for a specific proxy-host + */ + .get(async (req, res, next) => { + try { + const data = await validator( + { + required: ["host_id"], + additionalProperties: false, + properties: { + host_id: { + $ref: "common#/properties/id", + }, + type: { + type: "string", + enum: ["access", "error"], + }, + }, + }, + { + host_id: req.params.host_id, + type: req.query.type || "access", + }, + ); + + const hostId = Number.parseInt(data.host_id, 10); + const logType = data.type === "error" ? "error" : "access"; + const logFile = `/data/logs/proxy-host-${hostId}_${logType}.log`; + + // Check access permission + await res.locals.access.can("proxy_hosts:get", hostId); + + let logs = ""; + if (fs.existsSync(logFile)) { + const content = fs.readFileSync(logFile, { encoding: "utf8" }); + const lines = content.split("\n"); + // Return last 1000 lines to avoid huge payloads + const maxLines = 1000; + if (lines.length > maxLines) { + logs = lines.slice(-maxLines).join("\n"); + } else { + logs = content; + } + } + + res.status(200).send({ logs }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + export default router; diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 3ac6462136..90e6e23bb2 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -124,6 +124,9 @@ }, "advanced_config": { "type": "string" + }, + "access_list_id": { + "$ref": "../common.json#/properties/access_list_id" } } }, @@ -132,7 +135,8 @@ "path": "/app", "forward_scheme": "http", "forward_host": "example.com", - "forward_port": 80 + "forward_port": 80, + "access_list_id": 0 } ] }, diff --git a/frontend/src/api/backend/getProxyHostLogs.ts b/frontend/src/api/backend/getProxyHostLogs.ts new file mode 100644 index 0000000000..91944c9d72 --- /dev/null +++ b/frontend/src/api/backend/getProxyHostLogs.ts @@ -0,0 +1,8 @@ +import * as api from "./base"; + +export async function getProxyHostLogs(id: number, type: "access" | "error" = "access"): Promise<{ logs: string }> { + return await api.get({ + url: `/nginx/proxy-hosts/${id}/logs`, + params: { type }, + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..6be435b6a8 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -27,6 +27,7 @@ export * from "./getDeadHosts"; export * from "./getHealth"; export * from "./getHostsReport"; export * from "./getProxyHost"; +export * from "./getProxyHostLogs"; export * from "./getProxyHosts"; export * from "./getRedirectionHost"; export * from "./getRedirectionHosts"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..4818f77699 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -103,6 +103,8 @@ export interface ProxyLocation { forwardScheme: string; forwardHost: string; forwardPort: number; + accessListId?: number; + accessList?: AccessList; } export interface ProxyHost { diff --git a/frontend/src/components/Form/AccessField.tsx b/frontend/src/components/Form/AccessField.tsx index afcbd0cf7d..1c1004e711 100644 --- a/frontend/src/components/Form/AccessField.tsx +++ b/frontend/src/components/Form/AccessField.tsx @@ -31,14 +31,18 @@ interface Props { id?: string; name?: string; label?: string; + onFormChange?: (value: number) => void; } -export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) { +export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId", onFormChange }: Props) { const { locale } = useLocaleState(); const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); const { setFieldValue } = useFormikContext(); const handleChange = (newValue: any, _actionMeta: ActionMeta) => { setFieldValue(name, newValue?.value); + if (onFormChange) { + onFormChange(newValue?.value ?? 0); + } }; const options: AccessOption[] = diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 4240b1f986..9422bd8933 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -5,6 +5,7 @@ import { useFormikContext } from "formik"; import { useState } from "react"; import type { ProxyLocation } from "src/api/backend"; import { intl, T } from "src/locale"; +import { AccessField } from "./AccessField"; import styles from "./LocationsFields.module.css"; interface Props { @@ -22,6 +23,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { forwardScheme: "http", forwardHost: "", forwardPort: 80, + accessListId: 0, }; const toggleAdvVisible = (idx: number) => { @@ -44,6 +46,12 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { setFormField(newValues); }; + const handleAccessListChange = (idx: number, accessListId: number) => { + const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, accessListId } : v)); + setValues(newValues); + setFormField(newValues); + }; + const setFormField = (newValues: ProxyLocation[]) => { const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== ""); setFieldValue(name, filtered); @@ -141,6 +149,12 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { + handleAccessListChange(idx, value)} + /> {advVisible.includes(idx) && (
{ + return useQuery<{ logs: string }, Error>({ + queryKey: ["proxy-host-logs", id, type], + queryFn: () => getProxyHostLogs(id, type), + staleTime: 10_000, + }); +}; + +export { useProxyHostLogs }; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..b5043fc83a 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -122,6 +122,9 @@ "action.enable": { "defaultMessage": "Enable" }, + "action.logs": { + "defaultMessage": "Logs" + }, "action.permissions": { "defaultMessage": "Permissions" }, @@ -248,6 +251,9 @@ "column.access": { "defaultMessage": "Access" }, + "column.error": { + "defaultMessage": "Error" + }, "column.authorization": { "defaultMessage": "Authorization" }, diff --git a/frontend/src/modals/HostLogsModal.tsx b/frontend/src/modals/HostLogsModal.tsx new file mode 100644 index 0000000000..a0df04ee71 --- /dev/null +++ b/frontend/src/modals/HostLogsModal.tsx @@ -0,0 +1,93 @@ +import CodeEditor from "@uiw/react-textarea-code-editor"; +import EasyModal, { type InnerModalProps } from "ez-modal-react"; +import { useState } from "react"; +import { Alert } from "react-bootstrap"; +import Modal from "react-bootstrap/Modal"; +import { Button, Loading } from "src/components"; +import { useProxyHostLogs } from "src/hooks"; +import { T } from "src/locale"; + +const showHostLogsModal = (id: number) => { + EasyModal.show(HostLogsModal, { id }); +}; + +interface Props extends InnerModalProps { + id: number; +} +const HostLogsModal = EasyModal.create(({ id, visible, remove }: Props) => { + const [logType, setLogType] = useState<"access" | "error">("access"); + const { data, isLoading, error } = useProxyHostLogs(id, logType); + + return ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + + + + + + + + {isLoading ? ( + + ) : ( +
+ +
+ )} +
+ + + +
+ ); +}); + +export { showHostLogsModal }; diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index a06a0c0d71..902d0d73d0 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -6,6 +6,7 @@ export * from "./DeleteConfirmModal"; export * from "./DNSCertificateModal"; export * from "./EventDetailsModal"; export * from "./HelpModal"; +export * from "./HostLogsModal"; export * from "./HTTPCertificateModal"; export * from "./PermissionsModal"; export * from "./ProxyHostModal"; diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index 5af58081ad..ad6714b79d 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -1,4 +1,4 @@ -import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react"; +import { IconDotsVertical, IconEdit, IconFileText, IconPower, IconTrash } from "@tabler/icons-react"; import { createColumnHelper, getCoreRowModel, @@ -28,9 +28,10 @@ interface Props { onEdit?: (id: number) => void; onDelete?: (id: number) => void; onDisableToggle?: (id: number, enabled: boolean) => void; + onLogs?: (id: number) => void; onNew?: () => void; } -export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { +export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onLogs, onNew, isFiltered }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( () => [ @@ -115,19 +116,30 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog data={{ id: info.row.original.id }} /> + { + e.preventDefault(); + onEdit?.(info.row.original.id); + }} + > + + + + { e.preventDefault(); - onEdit?.(info.row.original.id); + onLogs?.(info.row.original.id); }} > - - + + - - { @@ -160,7 +172,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog }, }), ], - [columnHelper, onEdit, onDisableToggle, onDelete], + [columnHelper, onEdit, onDisableToggle, onDelete, onLogs], ); const [sorting, setSorting] = useState([]); diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index 5d6602e2db..7845fb029b 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -6,7 +6,7 @@ import { deleteProxyHost, toggleProxyHost } from "src/api/backend"; import { Button, HasPermission, LoadingPage } from "src/components"; import { useProxyHosts } from "src/hooks"; import { T } from "src/locale"; -import { showDeleteConfirmModal, showHelpModal, showProxyHostModal } from "src/modals"; +import { showDeleteConfirmModal, showHelpModal, showHostLogsModal, showProxyHostModal } from "src/modals"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; import { showObjectSuccess } from "src/notifications"; import Table from "./Table"; @@ -99,6 +99,7 @@ export default function TableWrapper() { isFiltered={!!search} isFetching={isFetching} onEdit={(id: number) => showProxyHostModal(id)} + onLogs={(id: number) => showHostLogsModal(id)} onDelete={(id: number) => { const host = data?.find((h) => h.id === id); showDeleteConfirmModal({ From 35ad8227d765541ac72a3099fb265acd830b4d6e Mon Sep 17 00:00:00 2001 From: "Dimas R. Wisnu" Date: Fri, 7 Aug 2026 17:01:20 +0700 Subject: [PATCH 03/55] fix: resolve biome lint errors - Use optional chaining for nullable checks - Replace template literals with string literals for plain SQL - Add node: protocol to fs/promises import in setup.js --- backend/internal/access-list.js | 18 +++++++++--------- backend/internal/proxy-host.js | 2 +- backend/setup.js | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 413ac067af..f8c4547944 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -28,17 +28,17 @@ const getProxyHostsUsingAccessListInLocations = async (accessListId) => { if (isMysql()) { const searchObj = JSON.stringify([{ access_list_id: accessListId }]); result = await db().raw( - `SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)`, + "SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)", [searchObj, "$"], ); } else if (isPostgres()) { result = await db().raw( - `SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb`, + "SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb", [JSON.stringify([{ access_list_id: accessListId }])], ); } else { result = await db().raw( - `SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?`, + "SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?", [`%"access_list_id":${accessListId}%`], ); } @@ -222,10 +222,10 @@ const internalAccessList = { // Also regenerate configs for proxy hosts that reference this access list in their locations const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id); - if (locationHostRows && locationHostRows.length) { + if (locationHostRows?.length) { const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => { // Exclude hosts already regenerated above - return !freshRow.proxy_hosts || !freshRow.proxy_hosts.find((h) => h.id === id); + return !freshRow.proxy_hosts?.find((h) => h.id === id); }); if (locationHostIds.length) { const locationHosts = await proxyHostModel.query() @@ -235,7 +235,7 @@ const internalAccessList = { .withGraphFetched("[owner, certificate, access_list.[clients,items]]"); for (const host of locationHosts) { // Fetch access lists for locations - if (host.locations && host.locations.length) { + if (host.locations?.length) { for (let i = 0; i < host.locations.length; i++) { const loc = host.locations[i]; if (loc.access_list_id && loc.access_list_id > 0) { @@ -363,15 +363,15 @@ const internalAccessList = { // Also handle proxy hosts that reference this access list in their locations JSON const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id); - if (locationHostRows && locationHostRows.length) { + if (locationHostRows?.length) { const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => { - return !row.proxy_hosts || !row.proxy_hosts.find((h) => h.id === id); + return !row.proxy_hosts?.find((h) => h.id === id); }); if (locationHostIds.length) { // Clear the access_list_id in locations JSON for these hosts for (const hostId of locationHostIds) { const host = await proxyHostModel.query().where("id", hostId).first(); - if (host && host.locations) { + if (host?.locations) { const updatedLocations = host.locations.map((loc) => { if (loc.access_list_id === row.id) { return { ...loc, access_list_id: 0 }; diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 96cfab480e..6bd0be1a66 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -21,7 +21,7 @@ const omissions = () => { * @returns {Promise} */ const fetchLocationAccessLists = async (host) => { - if (!host.locations || !host.locations.length) { + if (!host.locations?.length) { return; } for (let i = 0; i < host.locations.length; i++) { diff --git a/backend/setup.js b/backend/setup.js index c0418e170b..362cbdfe43 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -6,7 +6,7 @@ import certificateModel from "./models/certificate.js"; import settingModel from "./models/setting.js"; import userModel from "./models/user.js"; import userPermissionModel from "./models/user_permission.js"; -import fs from "fs/promises"; +import fs from "node:fs/promises"; export const isSetup = async () => { const row = await userModel.query().select("id").where("is_deleted", 0).first(); From 0453ddca350b0607501d163a0519ff9e1329901d Mon Sep 17 00:00:00 2001 From: vzagorovskiy Date: Fri, 28 Aug 2026 12:02:52 +0300 Subject: [PATCH 04/55] Use the row's own model and host type in regenerate-config processItems() passed proxyHostModel and the literal "proxy_host" to configure() for every host type. host_type selects both the template and the output path, and each host type has its own id sequence, so redirection hosts, 404 hosts and streams were rendered through proxy_host.conf and written over /data/nginx/proxy_host/.conf. The proxy host sharing that id lost its config file and had the resulting nginx error recorded in its own meta. --- backend/scripts/regenerate-config | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/scripts/regenerate-config b/backend/scripts/regenerate-config index 00f8411310..1965777220 100755 --- a/backend/scripts/regenerate-config +++ b/backend/scripts/regenerate-config @@ -46,7 +46,7 @@ const logIt = (msg, type = "info") => logger[type]( // Let's do it. -const processItems = async (model, type) => { +const processItems = async (model, type, hostType) => { const rows = await model .query() .where("is_deleted", 0) @@ -60,17 +60,17 @@ const processItems = async (model, type) => { for (const row of rows) { if (!DRY_RUN) { logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`); - await internalNginx.configure(proxyHostModel, "proxy_host", row); + await internalNginx.configure(model, hostType, row); } else { logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`); } } }; -await processItems(proxyHostModel, "Proxy Host"); -await processItems(redirectionHostModel, "Redirection Host"); -await processItems(deadHostModel, "404 Host"); -await processItems(streamModel, "Stream"); +await processItems(proxyHostModel, "Proxy Host", "proxy_host"); +await processItems(redirectionHostModel, "Redirection Host", "redirection_host"); +await processItems(deadHostModel, "404 Host", "dead_host"); +await processItems(streamModel, "Stream", "stream"); logIt("Completed", "success"); process.exit(0); From 918728fcacaa6e3a5b2e9cf06eff90c0ba662747 Mon Sep 17 00:00:00 2001 From: Shawn Hank Date: Fri, 28 Aug 2026 17:12:35 -0600 Subject: [PATCH 05/55] Remove DNS provider credentials from disk after certbot runs The credentials file written for a DNS-01 challenge was only cleaned up when certbot failed - the unlink sat in a catch block. On success the file stayed in /etc/letsencrypt/credentials for the entire life of the certificate, holding a live DNS provider API token in plaintext. The file cannot simply be deleted at issuance, because certbot records its path in the renewal config and reads it back on every `certbot renew`. So the renew path now writes the file itself immediately before invoking certbot, and both paths remove it in a finally block. Net effect: the credentials exist on disk for the duration of a certbot run rather than permanently. The value still lives in the certificates table, which is unavoidable - it has to come from somewhere to be written at all. renewLetsEncryptSslWithDnsChallenge reads the row directly from the model because renew() sources its certificate from internalCertificate.get(), which strips meta.dns_provider_credentials via omissions(). --- backend/internal/certificate.js | 69 ++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 6498422c61..963d2bb6a4 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -881,10 +881,20 @@ const internalCertificate = { const result = await utils.execFile(certbotCommand, args, adds.opts); logger.info(result); return result; - } catch (err) { - // Don't fail if file does not exist, so no need for action in the callback + } finally { + // Remove the credentials file whether certbot succeeded or failed. + // + // This cleanup used to sit in a catch block, so it only ran when issuance FAILED. + // A certificate that issued successfully left its DNS provider API credentials in + // /etc/letsencrypt/credentials for the entire life of that certificate. Nothing + // reads the file between certbot runs, so there is no reason to keep it: + // renewLetsEncryptSslWithDnsChallenge() writes it again immediately before each + // renewal. + // + // unlink is fire-and-forget with an empty callback. If the file is already gone + // that is the end state we wanted anyway, and a missing file must never turn a + // successful issuance into a failure. fs.unlink(credentialsLocation, () => {}); - throw err; } }, @@ -981,6 +991,43 @@ const internalCertificate = { `Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`, ); + // certbot reads the DNS credentials back from the path recorded in the renewal config + // it wrote at issuance time, for example: + // + // authenticator = dns-cloudflare + // dns_cloudflare_credentials = /etc/letsencrypt/credentials/credentials-27 + // + // so the file has to be present for the duration of this run. Write it here and remove + // it again below rather than leaving it on disk between renewals. + // + // Leaving it is an avoidable exposure. Anything running as root - a compromised + // process, a script, malware - can read the token and use it to issue valid Let's + // Encrypt certificates for the domain. Those certificates are genuinely trusted, so + // traffic presented with them passes TLS inspection, IDS/IPS and DLP that would + // otherwise flag it, and an exfiltration path built on them looks like ordinary + // HTTPS. The exposure window should be one certbot run, not the life of the + // certificate. + // + // The value is not on the certificate object we were handed: renew() sources that from + // internalCertificate.get(), which pipes the row through utils.omitRow(omissions()) so + // meta.dns_provider_credentials can never travel out over the API. Read the row from + // the model directly to get at it. + const row = await certificateModel.query().where("id", certificate.id).first(); + const credentials = row?.meta?.dns_provider_credentials; + const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; + + if (credentials) { + fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true }); + fs.writeFileSync(credentialsLocation, credentials, { mode: 0o600 }); + } else { + // Nothing stored to write. A certificate issued under the previous behaviour may + // still have its file on disk; leave it be and let certbot decide. Throwing here + // would break a renewal that would otherwise have succeeded. + logger.warn( + `No stored DNS credentials for Cert #${certificate.id}; relying on any existing ${credentialsLocation}`, + ); + } + const args = [ "renew", "--force-renewal", @@ -1008,9 +1055,19 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); - const result = await utils.execFile(certbotCommand, args, adds.opts); - logger.info(result); - return result; + try { + const result = await utils.execFile(certbotCommand, args, adds.opts); + logger.info(result); + return result; + } finally { + // Only clean up a file we put there ourselves. If `credentials` came back empty we + // wrote nothing, and an older file left on disk by the previous behaviour is the + // only thing keeping that certificate renewable - deleting it would break the next + // run for no gain. + if (credentials) { + fs.unlink(credentialsLocation, () => {}); + } + } }, /** From 210366cca0a504b997b8e739392fa62bb6dce5e0 Mon Sep 17 00:00:00 2001 From: Shawn Hank Date: Sat, 29 Aug 2026 00:11:15 -0600 Subject: [PATCH 06/55] Stop recreating DNS credentials files on every backend restart setupCertbotPlugins() wrote a credentials file for every DNS-01 certificate each time the backend started, using flag "wx" so it only filled in missing ones. That existed because the renew path did not write the file itself, so something had to put it back before `certbot renew` looked for it. With the previous commit the renew path writes the file immediately before invoking certbot, so this is now the only thing putting those credentials back on disk - and it does so for every certificate on every restart, which undoes the cleanup entirely. Removing the write leaves the `fs` import and the `promises` array unused. The "Added Certbot plugins" log line is kept but now gates on plugins.length, since it was previously gated on a promise array that only ever held credential writes. --- backend/setup.js | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/backend/setup.js b/backend/setup.js index f6b1454434..6766a18a8b 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import { installPlugins } from "./lib/certbot.js"; import utils from "./lib/utils.js"; import { setup as logger } from "./logger.js"; @@ -98,7 +97,6 @@ const setupCertbotPlugins = async () => { if (certificates?.length) { const plugins = []; - const promises = []; certificates.map((certificate) => { if (certificate.meta && certificate.meta.dns_challenge === true) { @@ -106,31 +104,23 @@ const setupCertbotPlugins = async () => { plugins.push(certificate.meta.dns_provider); } - // Make sure credentials file exists - const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; - if (typeof certificate.meta.dns_provider_credentials === "string") { - promises.push( - fs - .mkdir("/etc/letsencrypt/credentials", { recursive: true }) - .then(() => - fs.writeFile(credentials_loc, certificate.meta.dns_provider_credentials, { - mode: 0o600, - flag: "wx", - }), - ) - .catch((err) => { - if (err.code !== "EEXIST") throw err; - }), - ); - } + // Deliberately does NOT write the DNS credentials file here any more. + // + // It used to, so that a later `certbot renew` would find the path recorded in its + // renewal config. The effect was that every backend restart rewrote a plaintext + // DNS provider API token for every DNS-01 certificate, and left it there. + // + // internalCertificate now writes that file immediately before it runs certbot and + // removes it again afterwards, so there is exactly one writer and the credential + // is on disk only for the length of a certbot run. Recreating the files at boot + // would put every one of them straight back. } return true; }); await installPlugins(plugins); - if (promises.length) { - await Promise.all(promises); + if (plugins.length) { logger.info(`Added Certbot plugins ${plugins.join(", ")}`); } } From 05b867c21d10a7e6a93b0442806b5c46c5fbca42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:58:04 +0000 Subject: [PATCH 07/55] Bump @humanfs/node from 0.16.7 to 0.16.8 in /test Bumps [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node) from 0.16.7 to 0.16.8. - [Release notes](https://github.com/humanwhocodes/humanfs/releases) - [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md) - [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node) --- updated-dependencies: - dependency-name: "@humanfs/node" dependency-version: 0.16.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- test/yarn.lock | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/test/yarn.lock b/test/yarn.lock index 7409eee5f0..49470672a8 100644 --- a/test/yarn.lock +++ b/test/yarn.lock @@ -168,19 +168,27 @@ "@eslint/core" "^1.2.1" levn "^0.4.1" -"@humanfs/core@^0.19.1": - version "0.19.1" - resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" - integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" "@humanfs/node@^0.16.6": - version "0.16.7" - resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" - integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== dependencies: - "@humanfs/core" "^0.19.1" + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" "@humanwhocodes/retry" "^0.4.0" +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" From 23b4e7d6f78f78d6fe3fb9aea34493001b9ea783 Mon Sep 17 00:00:00 2001 From: Christoph Vollmann Date: Fri, 4 Sep 2026 22:24:38 +0200 Subject: [PATCH 08/55] fix(certbot): switch the Azure DNS plugin to certbot-dns-azure-modern certbot-dns-azure 2.6.1 requires certbot<4.0; installing it into the image's certbot 5.x venv downgrades certbot and acme to 3.3.0, and acme 3.3.0 no longer imports against pyOpenSSL 26 (#5606). The maintained fork certbot-dns-azure-modern keeps module, entry point, flags and credentials format, requires certbot>=3.0 without an upper bound and declares its own azure-mgmt-dns range, so the extra dependency pin is no longer needed. --- backend/certbot/dns-plugins.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json index 73c5d5418b..3d917d289d 100644 --- a/backend/certbot/dns-plugins.json +++ b/backend/certbot/dns-plugins.json @@ -33,11 +33,11 @@ }, "azure": { "credentials": "# This plugin supported API authentication using either Service Principals or utilizing a Managed Identity assigned to the virtual machine.\n# Regardless which authentication method used, the identity will need the “DNS Zone Contributor” role assigned to it.\n# As multiple Azure DNS Zones in multiple resource groups can exist, the config file needs a mapping of zone to resource group ID. Multiple zones -> ID mappings can be listed by using the key dns_azure_zoneX where X is a unique number. At least 1 zone mapping is required.\n\n# Using a service principal (option 1)\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = E-xqXU83Y-jzTI6xe9fs2YC~mck3ZzUih9\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n\n# Using used assigned MSI (option 2)\n# dns_azure_msi_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\n\n# Using system assigned MSI (option 3)\n# dns_azure_msi_system_assigned = true\n\n# Zones (at least one always required)\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/dns1\ndns_azure_zone2 = example.org:/subscriptions/99800903-fb14-4992-9aff-12eaf2744622/resourceGroups/dns2", - "dependencies": "azure-mgmt-dns==8.2.0", + "dependencies": "", "full_plugin_name": "dns-azure", "name": "Azure", - "package_name": "certbot-dns-azure", - "version": "~=2.6.1" + "package_name": "certbot-dns-azure-modern", + "version": "~=2.8.0" }, "baidu": { "credentials": "dns_baidu_access_key = 12345678\ndns_baidu_secret_key = 1234567890abcdef1234567890abcdef", From e482522792b28899ae3f19e6d0a5b0dbe7e30569 Mon Sep 17 00:00:00 2001 From: Christoph Vollmann Date: Fri, 4 Sep 2026 22:55:19 +0200 Subject: [PATCH 09/55] fix(certbot): shorten the Azure credentials template The template was the longest of all plugins (1181 characters, nine comment lines) and carried the upstream example secret that trips secret scanning. It now shows what a user has to fill in: the service principal, and one zone line in the format ZONE_NAME:RESOURCE_GROUP_ID, plus a link to the docs for everything else. --- backend/certbot/dns-plugins.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json index 3d917d289d..d28d8e0ded 100644 --- a/backend/certbot/dns-plugins.json +++ b/backend/certbot/dns-plugins.json @@ -32,7 +32,7 @@ "version": ">=0.1.0" }, "azure": { - "credentials": "# This plugin supported API authentication using either Service Principals or utilizing a Managed Identity assigned to the virtual machine.\n# Regardless which authentication method used, the identity will need the “DNS Zone Contributor” role assigned to it.\n# As multiple Azure DNS Zones in multiple resource groups can exist, the config file needs a mapping of zone to resource group ID. Multiple zones -> ID mappings can be listed by using the key dns_azure_zoneX where X is a unique number. At least 1 zone mapping is required.\n\n# Using a service principal (option 1)\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = E-xqXU83Y-jzTI6xe9fs2YC~mck3ZzUih9\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n\n# Using used assigned MSI (option 2)\n# dns_azure_msi_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\n\n# Using system assigned MSI (option 3)\n# dns_azure_msi_system_assigned = true\n\n# Zones (at least one always required)\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/dns1\ndns_azure_zone2 = example.org:/subscriptions/99800903-fb14-4992-9aff-12eaf2744622/resourceGroups/dns2", + "credentials": "# Docs: https://cloudchristoph.github.io/certbot-dns-azure-modern/\n# Service principal with the \"DNS Zone Contributor\" role on the DNS zone\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = example-client-secret-not-real\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n# One line per DNS zone, format ZONE_NAME:RESOURCE_GROUP_ID\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/rg-dns", "dependencies": "", "full_plugin_name": "dns-azure", "name": "Azure", From 0596581ec9b2dea310f8f866cb649b0e54da84ba Mon Sep 17 00:00:00 2001 From: Crazy D <32867828+hurole@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:25:54 +0800 Subject: [PATCH 10/55] feat: add Tencent Cloud EdgeOne DNS provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary This PR adds support for **Tencent Cloud EdgeOne (TEO)** as a DNS provider for Let's Encrypt DNS-01 certificate validation using the [`certbot-dns- edgeone`]() plugin. ### Details - **Provider Name:** Tencent Cloud EdgeOne - **Plugin Name:** `dns-edgeone` - **PyPI Package:** [`certbot-dns-edgeone`](https://pypi.org/project/certbot-dns-edgeone/) (v0.1.0+) - **Plugin Repository:** https://github.com/hurole/certbot-dns-edgeone - **License:** Apache-2.0 ### Credentials Template ```ini dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID dns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY ``` ### Verification • Verified certbot-dns-edgeone package installation and entrypoint discovery with Certbot. • Verified DNS-01 TXT record creation and cleanup flows via unit tests. • Formatted in backend/certbot/dns-plugins.json in alphabetical order. --- backend/certbot/dns-plugins.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json index 73c5d5418b..0109d0c4d2 100644 --- a/backend/certbot/dns-plugins.json +++ b/backend/certbot/dns-plugins.json @@ -231,6 +231,14 @@ "package_name": "certbot-plugin-edgedns", "version": "~=0.1.0" }, + "edgeone": { + "credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY", + "dependencies": "", + "full_plugin_name": "dns-edgeone", + "name": "Tencent Cloud EdgeOne", + "package_name": "certbot-dns-edgeone", + "version": ">=0.1.0" + }, "eurodns": { "credentials": "dns_eurodns_applicationId = myuser\ndns_eurodns_apiKey = mysecretpassword\ndns_eurodns_endpoint = https://rest-api.eurodns.com/dns-zones/", "dependencies": "", From f2b1711549686f824c47fbcbd1eafb35049d40d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:38:16 +0000 Subject: [PATCH 11/55] build(deps): bump qs from 6.15.3 to 6.16.0 in /backend Bumps [qs](https://github.com/ljharb/qs) from 6.15.3 to 6.16.0. - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.15.3...v6.16.0) --- updated-dependencies: - dependency-name: qs dependency-version: 6.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- backend/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/yarn.lock b/backend/yarn.lock index 6e2ab5dbcf..2f26813c3d 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1918,9 +1918,9 @@ pump@^3.0.0: once "^1.3.1" qs@^6.14.0, qs@^6.15.2: - version "6.15.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" - integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== dependencies: es-define-property "^1.0.1" side-channel "^1.1.1" From dba90c36be3f52f0f8bead0b51ba933e64c4880f Mon Sep 17 00:00:00 2001 From: "hr.deng" Date: Sun, 20 Sep 2026 15:19:13 +0800 Subject: [PATCH 12/55] style: fix indentation in dns-plugins.json --- backend/certbot/dns-plugins.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/certbot/dns-plugins.json b/backend/certbot/dns-plugins.json index 0109d0c4d2..ad54c949a3 100644 --- a/backend/certbot/dns-plugins.json +++ b/backend/certbot/dns-plugins.json @@ -232,13 +232,13 @@ "version": "~=0.1.0" }, "edgeone": { - "credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY", - "dependencies": "", - "full_plugin_name": "dns-edgeone", - "name": "Tencent Cloud EdgeOne", - "package_name": "certbot-dns-edgeone", - "version": ">=0.1.0" - }, + "credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY", + "dependencies": "", + "full_plugin_name": "dns-edgeone", + "name": "Tencent Cloud EdgeOne", + "package_name": "certbot-dns-edgeone", + "version": ">=0.1.0" + }, "eurodns": { "credentials": "dns_eurodns_applicationId = myuser\ndns_eurodns_apiKey = mysecretpassword\ndns_eurodns_endpoint = https://rest-api.eurodns.com/dns-zones/", "dependencies": "", From d11e0f6c5190b91cf382f7008272819c4016979f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:04:22 +0000 Subject: [PATCH 13/55] build(deps): bump the prod-patch-updates group across 1 directory with 2 updates Bumps the prod-patch-updates group with 2 updates in the /frontend directory: [query-string](https://github.com/sindresorhus/query-string) and [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). Updates `query-string` from 9.5.0 to 9.5.1 - [Release notes](https://github.com/sindresorhus/query-string/releases) - [Commits](https://github.com/sindresorhus/query-string/compare/v9.5.0...v9.5.1) Updates `react-router-dom` from 7.18.2 to 7.18.4 - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.18.4/packages/react-router-dom/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.4/packages/react-router-dom) --- updated-dependencies: - dependency-name: query-string dependency-version: 9.5.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: react-router-dom dependency-version: 7.18.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] --- frontend/package.json | 4 ++-- frontend/yarn.lock | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index ea99d71de8..f91debcc2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,14 +28,14 @@ "formik": "^2.4.9", "generate-password-browser": "^1.1.0", "humps": "^2.0.1", - "query-string": "^9.5.0", + "query-string": "^9.5.1", "react": "^19.3.0", "react-bootstrap": "^2.10.10", "react-dom": "^19.3.0", "react-intl": "^10.2.0", "react-markdown": "^10.1.0", "react-qr-code": "^2.2.0", - "react-router-dom": "^7.18.2", + "react-router-dom": "^7.18.4", "react-select": "^5.10.2", "react-toastify": "^11.1.0", "rooks": "^9.9.0" diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6c2a311042..90f1d65d74 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2266,10 +2266,10 @@ qrcode-generator@^2.0.4: resolved "https://registry.yarnpkg.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz#e8b3f30922577eba52078aa9c0d5a2a74fe1fd94" integrity sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g== -query-string@^9.5.0: - version "9.5.0" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.0.tgz#e6f4003dcb321580dd043109b6fbe2a3890afff6" - integrity sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA== +query-string@^9.5.1: + version "9.5.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.1.tgz#aecdc091d3dc7ce293eed83957e220d523a3d0f7" + integrity sha512-/zO3RwuRCMTIcEgq6YMv4OrtEE1XzBG7w5N6zc6ydYnkWYOWsnLI/5894hYEzfESfMOT4cHCsRTIdxsSl1KjGg== dependencies: decode-uri-component "^0.5.0" filter-obj "^5.1.0" @@ -2384,17 +2384,17 @@ react-qr-code@^2.2.0: prop-types "^15.8.1" qrcode-generator "^2.0.4" -react-router-dom@^7.18.2: - version "7.18.2" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.2.tgz#00bf1d0ce8bf17a6d831949aacda72a6120e4926" - integrity sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw== +react-router-dom@^7.18.4: + version "7.18.4" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.4.tgz#07b72b8aab2d80af847425107f966f1688c703d0" + integrity sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw== dependencies: - react-router "7.18.2" + react-router "7.18.4" -react-router@7.18.2: - version "7.18.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.2.tgz#a76c46ce9e5edacd4f51289d5a71f71305db9152" - integrity sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg== +react-router@7.18.4: + version "7.18.4" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.4.tgz#dc30cbb27e936f06d9e1d75ab648b317deb698a4" + integrity sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0" From c8fe9abe38497fdc065be67c6e04af309dd6c511 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:04:45 +0000 Subject: [PATCH 14/55] build(deps-dev): bump the dev-patch-updates group across 1 directory with 5 updates Bumps the dev-patch-updates group with 5 updates in the /frontend directory: | Package | From | To | | --- | --- | --- | | [@formatjs/cli](https://github.com/formatjs/formatjs) | `6.16.19` | `6.16.30` | | [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` | | [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` | | [postcss](https://github.com/postcss/postcss) | `8.5.26` | `8.5.28` | Updates `@formatjs/cli` from 6.16.19 to 6.16.30 - [Release notes](https://github.com/formatjs/formatjs/releases) - [Commits](https://github.com/formatjs/formatjs/compare/@formatjs/cli@6.16.19...@formatjs/cli@6.16.30) Updates `@testing-library/dom` from 10.4.1 to 10.4.2 - [Release notes](https://github.com/testing-library/dom-testing-library/releases) - [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2) Updates `@testing-library/react` from 16.3.2 to 16.3.3 - [Release notes](https://github.com/testing-library/react-testing-library/releases) - [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3) Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react) Updates `postcss` from 8.5.26 to 8.5.28 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.26...8.5.28) --- updated-dependencies: - dependency-name: "@formatjs/cli" dependency-version: 6.16.30 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@testing-library/dom" dependency-version: 10.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@testing-library/react" dependency-version: 16.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitejs/plugin-react" dependency-version: 6.1.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: postcss dependency-version: 8.5.28 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] --- frontend/package.json | 10 ++-- frontend/yarn.lock | 122 +++++++++++++++++++++--------------------- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index ea99d71de8..ec7a72b05a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,20 +42,20 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.10", - "@formatjs/cli": "^6.16.19", + "@formatjs/cli": "^6.16.30", "@tanstack/react-query-devtools": "^5.101.4", - "@testing-library/dom": "^10.4.1", + "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", - "@testing-library/react": "^16.3.2", + "@testing-library/react": "^16.3.3", "@types/country-flag-icons": "^1.2.2", "@types/humps": "^2.0.6", "@types/node": "^26.2.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/react-table": "^7.7.20", - "@vitejs/plugin-react": "^6.1.0", + "@vitejs/plugin-react": "^6.1.1", "happy-dom": "^20.11.6", - "postcss": "^8.5.26", + "postcss": "^8.5.28", "postcss-simple-vars": "^7.0.1", "sass": "^1.103.1", "tmp": "^0.2.7", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6c2a311042..95e04ee4dc 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -254,47 +254,47 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.12.tgz#afefe785949f16ac4cdd1e695935a321572dd56a" integrity sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww== -"@formatjs/cli-native-darwin-arm64@1.1.13": - version "1.1.13" - resolved "https://registry.yarnpkg.com/@formatjs/cli-native-darwin-arm64/-/cli-native-darwin-arm64-1.1.13.tgz#b9bb994d3b0043061deacf18b6431e452338f67f" - integrity sha512-YFmGCKyIgAo/yQ6r156Ty1BTRVHxR85kPFXQ5AhpQoHO9z6DE+iVo5sOcu/RaomwNTzPELP5nY7iqiPXqxnPoQ== - -"@formatjs/cli-native-linux-arm64-musl@1.0.11": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-arm64-musl/-/cli-native-linux-arm64-musl-1.0.11.tgz#346b1a0396de28ed06d2d37c83e661bd4e200e4f" - integrity sha512-/0QMXaWCdqi1ZWwWuHn2Po593oCWBuZAohf9OVjnUvuSfZyQs3aD5DZVvJuai7bCeBDArN40fv8RrD8bI4F89Q== - -"@formatjs/cli-native-linux-arm64@1.2.13": - version "1.2.13" - resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-arm64/-/cli-native-linux-arm64-1.2.13.tgz#09e6cd544d41daf12134789d4441ddb2470dad4e" - integrity sha512-5T79kvS6fUXyTrt1EWhFtWJJucPX39TbSHPAMSXl2SdRgTWlRZcMAplnpg18JMjXUcsRySeLQiCgfX+0al+0Gw== - -"@formatjs/cli-native-linux-x64-musl@1.0.11": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-x64-musl/-/cli-native-linux-x64-musl-1.0.11.tgz#72efee12b3fee10637b28043cc67e3dfe086fa03" - integrity sha512-+hQH5fsuo7Mrdm2Omzmp6foTDP8MmrLU07LbyThZ4EtGV4Q1t9in3tgnD9/+lkfmqVfugYSHJQeMK6zpm84Ovw== - -"@formatjs/cli-native-linux-x64@1.1.13": - version "1.1.13" - resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-x64/-/cli-native-linux-x64-1.1.13.tgz#d4a69a0fd36dbac165f7e2d2a659f0c09f23659c" - integrity sha512-tG+8iLAC0b6PKAIqijYktzkFIDjZShmPuQUlKxD7G3WrNAHSNOVXJ8+Cf49GTYeYP5zBw/NzeElhgud+LBx46A== - -"@formatjs/cli-native-win32-x64@1.1.14": - version "1.1.14" - resolved "https://registry.yarnpkg.com/@formatjs/cli-native-win32-x64/-/cli-native-win32-x64-1.1.14.tgz#5c2f5731a6c99cb647ff0932bc3caf3345a321ba" - integrity sha512-lNHPcA5rRacs/6nWOC+CCQ45Q/QY1zc0kLXjtss4h4cue5ShR2aPHEHBONRDjDWuuqbkolhIC5lTFD6Dg12eGQ== - -"@formatjs/cli@^6.16.19": - version "6.16.19" - resolved "https://registry.yarnpkg.com/@formatjs/cli/-/cli-6.16.19.tgz#b92ab015bbb7370ef967a6c4ca7aafb46545fa9a" - integrity sha512-/0F4n6To1wvroswFT+/3CE+eA8SRi/6y/22OrWmanNVwyeqpkUTHreQy2QzZDgFROH/C/b1tG7qxmQdPCoi1Vw== +"@formatjs/cli-native-darwin-arm64@1.1.24": + version "1.1.24" + resolved "https://registry.yarnpkg.com/@formatjs/cli-native-darwin-arm64/-/cli-native-darwin-arm64-1.1.24.tgz#fd3eacb6653b0fdd81f123defbb6752072478a97" + integrity sha512-2i/fKUcawvTe022vN1LcpCJMBHcN2pFgOzg3Hr24jV0QMpoZVpB5RuM8fhZCy9E/STnOTP+P5W9uuPhg5eCGUA== + +"@formatjs/cli-native-linux-arm64-musl@1.0.22": + version "1.0.22" + resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-arm64-musl/-/cli-native-linux-arm64-musl-1.0.22.tgz#bd9939c40cfc45d3e2ed73200906dd9aa1aa43aa" + integrity sha512-YUJ1ArreQGEdr2VaBIvpUVx3242YRFAwaKljlm0bOAckdk1JogzGnxIb5OJzFxlKiZkXWGFlO+erpRB6q0+kHg== + +"@formatjs/cli-native-linux-arm64@1.2.24": + version "1.2.24" + resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-arm64/-/cli-native-linux-arm64-1.2.24.tgz#8ff72faeaa658f69a2f2dc1491decc243ff08411" + integrity sha512-L0hFov7dgC6m9WM2cqolaL3zA48vpwe/gqd7MKILt+TrHR4ZlVv9CXvzXMDucn//FcTZw0NFBKgVXcHvlimY2g== + +"@formatjs/cli-native-linux-x64-musl@1.0.22": + version "1.0.22" + resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-x64-musl/-/cli-native-linux-x64-musl-1.0.22.tgz#5b137b928aff22b496ff5aea6a8f974d4188f149" + integrity sha512-hUwkDw47LQ0ESJvKsbZOWI50w/NP1hATfRrUy9mGRCRqKj1YTJ/HQxOjVfnrpiVEImjs4Ptws69RuIgxB5QGOA== + +"@formatjs/cli-native-linux-x64@1.1.24": + version "1.1.24" + resolved "https://registry.yarnpkg.com/@formatjs/cli-native-linux-x64/-/cli-native-linux-x64-1.1.24.tgz#23a421980b85a60266dbccd1d236d575ebdda54e" + integrity sha512-bp6FKYz8AQaHdeNI6rvMSk49ni4/uS+6AoxL/GiKXv2fL8zc459mkctTnwGR9Ol2in71Gq+nFKoZGFS50EHWCA== + +"@formatjs/cli-native-win32-x64@1.1.25": + version "1.1.25" + resolved "https://registry.yarnpkg.com/@formatjs/cli-native-win32-x64/-/cli-native-win32-x64-1.1.25.tgz#509060f7e0cc227a52e4677f6421da6fad48b224" + integrity sha512-tYjaYxtfBoGClwnBMtGuMQV/hbXY3mrGR5QRo9Tzk9Uwpa573gojtI8sqhn5jkzqjKPG1Ob3vVIJxcJmSnqemg== + +"@formatjs/cli@^6.16.30": + version "6.16.30" + resolved "https://registry.yarnpkg.com/@formatjs/cli/-/cli-6.16.30.tgz#436549695e81af050747abcfac3af366c96546fa" + integrity sha512-9pHgUriIJAsHfkoYuShydq5jHMp0IMQk7sdIkXgqD3m5UKTZIDfOyuJyuqJM5T55HOxz97y4iSSoWp7rfgdvNA== optionalDependencies: - "@formatjs/cli-native-darwin-arm64" "1.1.13" - "@formatjs/cli-native-linux-arm64" "1.2.13" - "@formatjs/cli-native-linux-arm64-musl" "1.0.11" - "@formatjs/cli-native-linux-x64" "1.1.13" - "@formatjs/cli-native-linux-x64-musl" "1.0.11" - "@formatjs/cli-native-win32-x64" "1.1.14" + "@formatjs/cli-native-darwin-arm64" "1.1.24" + "@formatjs/cli-native-linux-arm64" "1.2.24" + "@formatjs/cli-native-linux-arm64-musl" "1.0.22" + "@formatjs/cli-native-linux-x64" "1.1.24" + "@formatjs/cli-native-linux-x64-musl" "1.0.22" + "@formatjs/cli-native-win32-x64" "1.1.25" "@formatjs/fast-memoize@3.1.7": version "3.1.7" @@ -674,10 +674,10 @@ dependencies: "@tanstack/store" "^0.11.1" -"@testing-library/dom@^10.4.1": - version "10.4.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" - integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== +"@testing-library/dom@^10.4.2": + version "10.4.2" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.2.tgz#e996827c4e5e1f13589527293b4b3958de2f709f" + integrity sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" @@ -700,10 +700,10 @@ picocolors "^1.1.1" redent "^3.0.0" -"@testing-library/react@^16.3.2": - version "16.3.2" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" - integrity sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g== +"@testing-library/react@^16.3.3": + version "16.3.3" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.3.tgz#426907e7716f37038dab8aa69d8f0459f9ec24b2" + integrity sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg== dependencies: "@babel/runtime" "^7.12.5" @@ -981,10 +981,10 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.3.tgz#094041e1a4cb1987f038335421281ac8be390bcc" integrity sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg== -"@vitejs/plugin-react@^6.1.0": - version "6.1.0" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz#a3d7a3b998c618385d076e390b74398c3d8c65bc" - integrity sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw== +"@vitejs/plugin-react@^6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz#47fa1a2f956e88eaf778ebf65ddce8e2b36597e7" + integrity sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw== dependencies: "@rolldown/pluginutils" "^1.0.1" @@ -2097,10 +2097,10 @@ ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@^3.3.17: - version "3.3.18" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" - integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== +nanoid@^3.3.18: + version "3.3.19" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" + integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== node-addon-api@^7.0.0: version "7.1.1" @@ -2207,12 +2207,12 @@ postcss-simple-vars@^7.0.1: resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz#836b3097a54dcd13dbd3c36a5dbdd512fad2954c" integrity sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A== -postcss@^8.5.26: - version "8.5.26" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" - integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== +postcss@^8.5.26, postcss@^8.5.28: + version "8.5.28" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" + integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== dependencies: - nanoid "^3.3.17" + nanoid "^3.3.18" picocolors "^1.1.1" source-map-js "^1.2.1" From 50144205a0bcbbfbd00faf20e0c04c106d6aa139 Mon Sep 17 00:00:00 2001 From: BigHulk Date: Tue, 22 Sep 2026 18:22:57 +0800 Subject: [PATCH 15/55] fix(router): redirect authenticated login visits to dashboard --- frontend/src/Router.test.tsx | 79 ++++++++++++++++++++++++++++++++++++ frontend/src/Router.tsx | 3 +- 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 frontend/src/Router.test.tsx diff --git a/frontend/src/Router.test.tsx b/frontend/src/Router.test.tsx new file mode 100644 index 0000000000..bd61148073 --- /dev/null +++ b/frontend/src/Router.test.tsx @@ -0,0 +1,79 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import Router from "src/Router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { authState } = vi.hoisted(() => ({ authState: { authenticated: true } })); + +vi.mock("src/context", () => ({ useAuthState: () => authState })); +vi.mock("src/hooks", () => ({ + useHealth: () => ({ data: { status: "OK", setup: true }, isLoading: false, isError: false }), +})); +vi.mock("src/components", () => ({ + Page: ({ children }: { children: ReactNode }) => children, + SiteContainer: ({ children }: { children: ReactNode }) => children, + SiteHeader: () => null, + SiteMenu: () => null, + SiteFooter: () => null, + LoadingPage: () =>
Loading
, + Unhealthy: () =>
Unhealthy
, + ErrorNotFound: () =>

Not found

, +})); +vi.mock("src/pages/Dashboard", () => ({ default: () =>

Dashboard

})); +vi.mock("src/pages/Login", () => ({ default: () =>

Login

})); +vi.mock("src/pages/Nginx/ProxyHosts", () => ({ default: () =>

Proxy hosts

})); + +describe("Router", () => { + beforeEach(() => { + authState.authenticated = true; + window.history.replaceState(null, "", "/"); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it.each(["/login", "/login/", "/login?next=/users#form"])( + "redirects an authenticated visit to %s to the dashboard", + async (path) => { + window.history.replaceState(null, "", path); + const replaceState = vi.spyOn(window.history, "replaceState"); + render(); + + expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible(); + expect(window.location.pathname).toBe("/"); + expect(window.location.search).toBe(""); + expect(window.location.hash).toBe(""); + expect(replaceState).toHaveBeenCalledWith(expect.anything(), "", "/"); + }, + ); + + it("shows the login form when signed out and redirects after sign-in", async () => { + authState.authenticated = false; + window.history.replaceState(null, "", "/login"); + const { rerender } = render(); + + expect(await screen.findByRole("heading", { name: "Login" })).toBeVisible(); + expect(window.location.pathname).toBe("/login"); + + authState.authenticated = true; + rerender(); + + expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible(); + await waitFor(() => expect(window.location.pathname).toBe("/")); + }); + + it.each([ + ["/", "Dashboard"], + ["/nginx/proxy", "Proxy hosts"], + ["/unknown", "Not found"], + ])("preserves the existing route for %s", async (path, heading) => { + window.history.replaceState(null, "", path); + render(); + + expect(await screen.findByRole("heading", { name: heading })).toBeVisible(); + expect(window.location.pathname).toBe(path); + }); +}); diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894f..d0ee7b18b7 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { ErrorNotFound, LoadingPage, @@ -61,6 +61,7 @@ function Router() { }> } /> + } /> } /> } /> } /> From 4282d6c6e9ec68da07a48c65bc47194909bb28c4 Mon Sep 17 00:00:00 2001 From: carlosalbertorg Date: Tue, 22 Sep 2026 11:21:36 -0300 Subject: [PATCH 16/55] feat: Add a Logs viewer to the admin UI Nginx Proxy Manager had no way to inspect application or nginx logs from the web UI - admins had to shell into the container or read `docker logs`. This adds an admin-only Logs page that can tail: - the backend application log, now also mirrored to /data/logs/backend.log (in addition to stdout) and rotated by the logrotate timer that already runs every 2 days - the Let's Encrypt/certbot log, which certbot already writes to /data/logs/letsencrypt.log via its existing --logs-dir flag - per-host nginx access/error logs (proxy, redirection, 404 and stream hosts), with the file path always resolved server-side from a validated host_type enum + numeric host_id, never from client input Reads use a reverse chunked scan (64KB chunks, capped at 5MB scanned per request) instead of loading whole files into memory, and the frontend polls every 5s only while the tab is focused and "Live" is on, so this stays cheap on both CPU and memory. No new runtime dependencies were added on either side. Purely additive: two new admin-only endpoints (GET /api/logs/sources, GET /api/logs/tail), no existing behaviour changed. --- backend/internal/log-viewer.js | 229 ++++++++++++++++ backend/lib/access/logs-list.json | 7 + backend/logger.js | 122 ++++++++- backend/routes/logs.js | 106 ++++++++ backend/routes/main.js | 2 + .../schema/components/log-sources-object.json | 69 +++++ .../schema/components/log-tail-object.json | 30 +++ backend/schema/paths/logs/sources/get.json | 36 +++ backend/schema/paths/logs/tail/get.json | 100 +++++++ backend/schema/swagger.json | 14 + .../etc/logrotate.d/nginx-proxy-manager | 10 + frontend/.gitignore | 2 +- frontend/src/Router.tsx | 2 + frontend/src/api/backend/getLogSources.ts | 8 + frontend/src/api/backend/getLogTail.ts | 19 ++ frontend/src/api/backend/index.ts | 2 + frontend/src/api/backend/models.ts | 24 ++ frontend/src/components/SiteMenu.tsx | 7 + frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useLogSources.ts | 15 ++ frontend/src/hooks/useLogTail.ts | 25 ++ frontend/src/locale/src/en.json | 45 ++++ frontend/src/locale/src/et.json | 45 ++++ frontend/src/pages/Logs/LogLines.tsx | 76 ++++++ frontend/src/pages/Logs/LogViewer.module.css | 15 ++ frontend/src/pages/Logs/LogViewer.tsx | 249 ++++++++++++++++++ frontend/src/pages/Logs/index.tsx | 13 + 27 files changed, 1262 insertions(+), 12 deletions(-) create mode 100644 backend/internal/log-viewer.js create mode 100644 backend/lib/access/logs-list.json create mode 100644 backend/routes/logs.js create mode 100644 backend/schema/components/log-sources-object.json create mode 100644 backend/schema/components/log-tail-object.json create mode 100644 backend/schema/paths/logs/sources/get.json create mode 100644 backend/schema/paths/logs/tail/get.json create mode 100644 frontend/src/api/backend/getLogSources.ts create mode 100644 frontend/src/api/backend/getLogTail.ts create mode 100644 frontend/src/hooks/useLogSources.ts create mode 100644 frontend/src/hooks/useLogTail.ts create mode 100644 frontend/src/pages/Logs/LogLines.tsx create mode 100644 frontend/src/pages/Logs/LogViewer.module.css create mode 100644 frontend/src/pages/Logs/LogViewer.tsx create mode 100644 frontend/src/pages/Logs/index.tsx diff --git a/backend/internal/log-viewer.js b/backend/internal/log-viewer.js new file mode 100644 index 0000000000..abbaac5107 --- /dev/null +++ b/backend/internal/log-viewer.js @@ -0,0 +1,229 @@ +import fs from "node:fs"; +import errs from "../lib/error.js"; +import internalDeadHost from "./dead-host.js"; +import internalProxyHost from "./proxy-host.js"; +import internalRedirectionHost from "./redirection-host.js"; +import internalStream from "./stream.js"; + +const SYSTEM_LOG_FILE = "/data/logs/backend.log"; +const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log"; + +// Matches the access_log/error_log paths written by the nginx templates +// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf). +// This is a fixed, server-side lookup table - a host log path is always derived +// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied +// path or filename, so there is no path-traversal surface here. +const HOST_FILE_PREFIX = { + proxy: "proxy-host", + redirection: "redirection-host", + dead: "dead-host", + stream: "stream", +}; + +const DEFAULT_LINES = 200; +const MAX_LINES = 1000; +const CHUNK_SIZE = 64 * 1024; +// Never scan further back than this, regardless of how many lines were requested, +// so a huge or pathological log file can't turn a single request into unbounded I/O. +const MAX_SCAN_BYTES = 5 * 1024 * 1024; + +/** + * Reads at most `maxLines` lines from the end of a file, without loading the + * whole file into memory. Reads backwards in fixed-size chunks until enough + * newlines have been seen, the start of the file is reached, or the hard + * MAX_SCAN_BYTES ceiling is hit. + * + * @param {String} filePath + * @param {Number} maxLines + * @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>} + */ +const readLastLines = async (filePath, maxLines) => { + let handle; + try { + handle = await fs.promises.open(filePath, "r"); + const stat = await handle.stat(); + const { size } = stat; + + if (size === 0) { + return { lines: [], size: 0, truncated: false }; + } + + let position = size; + let scanned = 0; + let newlineCount = 0; + const chunks = []; + + while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) { + const readSize = Math.min(CHUNK_SIZE, position); + position -= readSize; + const buffer = Buffer.alloc(readSize); + await handle.read(buffer, 0, readSize, position); + scanned += readSize; + for (let i = buffer.length - 1; i >= 0; i--) { + if (buffer[i] === 0x0a) newlineCount++; + } + chunks.unshift(buffer); + } + + const truncated = position > 0 && scanned >= MAX_SCAN_BYTES; + + // If we didn't start reading from byte 0, the first line in our buffer is only + // partial *unless* it happens that `position` landed exactly on a line boundary + // (the byte right before it is a newline) - check that one byte to avoid + // silently dropping a perfectly valid line. + let startsOnLineBoundary = position === 0; + if (position > 0) { + const boundaryByte = Buffer.alloc(1); + await handle.read(boundaryByte, 0, 1, position - 1); + startsOnLineBoundary = boundaryByte[0] === 0x0a; + } + + const text = Buffer.concat(chunks).toString("utf8"); + const allLines = text.split("\n"); + + if (!startsOnLineBoundary && allLines.length > 0) { + allLines.shift(); + } + // Drop the trailing empty element caused by a final trailing newline. + if (allLines.length > 0 && allLines[allLines.length - 1] === "") { + allLines.pop(); + } + + return { lines: allLines.slice(-maxLines), size, truncated }; + } finally { + if (handle) { + await handle.close(); + } + } +}; + +/** + * Resolves a validated {type, host_type, host_id, channel} selection to the + * fixed, absolute path of the log file on disk. Throws if the combination + * isn't a recognised source. + * + * `channel` ("access" | "error") picks which of the two log files nginx writes + * per host - it's deliberately not named "stream" to avoid confusion with the + * "stream" host_type (TCP/UDP stream hosts). + * + * @param {Object} data + * @returns {String} + */ +const resolveFilePath = (data) => { + switch (data.type) { + case "system": + return SYSTEM_LOG_FILE; + case "letsencrypt": + return LETSENCRYPT_LOG_FILE; + case "host": { + const prefix = HOST_FILE_PREFIX[data.host_type]; + if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) { + throw new errs.ValidationError("Invalid host log source"); + } + return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`; + } + default: + throw new errs.ItemNotFoundError(data.type); + } +}; + +/** + * @param {Array} rows + * @returns {Array} + */ +const toHostOptions = (rows) => + rows.map((row) => ({ + id: row.id, + label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`, + })); + +const internalLogViewer = { + /** + * Lists the log sources available for the log viewer: the system (backend) + * log, the Let's Encrypt (certbot) log, and one entry per host the caller + * can see, for each host type. + * + * @param {Access} access + * @returns {Promise} + */ + listSources: async (access) => { + await access.can("logs:list"); + + const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([ + internalProxyHost.getAll(access), + internalRedirectionHost.getAll(access), + internalDeadHost.getAll(access), + internalStream.getAll(access), + ]); + + return { + system: { label: "System" }, + letsencrypt: { label: "Let's Encrypt" }, + hosts: { + proxy: toHostOptions(proxyHosts), + redirection: toHostOptions(redirectionHosts), + dead: toHostOptions(deadHosts), + stream: streams.map((row) => ({ + id: row.id, + label: `Port ${row.incoming_port} → ${row.forward_ip}:${row.forwarding_port}`, + })), + }, + }; + }, + + /** + * Returns the last N lines of the requested log source, optionally + * filtered by level and/or a plain-text search term. + * + * @param {Access} access + * @param {Object} data + * @param {String} data.type "system" | "letsencrypt" | "host" + * @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream" + * @param {Number} [data.host_id] + * @param {String} [data.channel] "access" | "error" + * @param {Number} [data.lines] + * @param {String} [data.level] + * @param {String} [data.search] + * @returns {Promise} + */ + tail: async (access, data) => { + await access.can("logs:list"); + + const filePath = resolveFilePath(data); + const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES); + + let result; + try { + result = await readLastLines(filePath, lines); + } catch (err) { + if (err.code === "ENOENT") { + return { lines: [], size: 0, truncated: false, exists: false }; + } + throw err; + } + + let outputLines = result.lines; + + // Only the system log is written in our own "LEVEL [scope]" format - level + // filtering on nginx/certbot lines would just match nothing and look like an + // empty log, so the filter is a no-op for any other source. + if (data.type === "system" && data.level) { + const needle = ` ${data.level.toUpperCase().padEnd(7)} `; + outputLines = outputLines.filter((line) => line.includes(needle)); + } + + if (data.search) { + const needle = data.search.toLowerCase(); + outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle)); + } + + return { + lines: outputLines, + size: result.size, + truncated: result.truncated, + exists: true, + }; + }, +}; + +export default internalLogViewer; diff --git a/backend/lib/access/logs-list.json b/backend/lib/access/logs-list.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/logs-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/logger.js b/backend/logger.js index 2b60dbff7b..cd845d5fbb 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import signale from "signale"; import { isDebugMode } from "./lib/config.js"; @@ -5,17 +7,115 @@ const opts = { logLevel: "info", }; -const global = new signale.Signale({ scope: "Global ", ...opts }); -const migrate = new signale.Signale({ scope: "Migrate ", ...opts }); -const express = new signale.Signale({ scope: "Express ", ...opts }); -const access = new signale.Signale({ scope: "Access ", ...opts }); -const nginx = new signale.Signale({ scope: "Nginx ", ...opts }); -const ssl = new signale.Signale({ scope: "SSL ", ...opts }); -const certbot = new signale.Signale({ scope: "Certbot ", ...opts }); -const importer = new signale.Signale({ scope: "Importer ", ...opts }); -const setup = new signale.Signale({ scope: "Setup ", ...opts }); -const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); -const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); +// Methods that are actually used across the codebase (see grep of `.info(`, `.warn(`, etc). +// Only these are mirrored to the log file - decorative signale methods (star, note, watch, ...) +// are left console-only since they carry no diagnostic value worth persisting. +const PERSISTED_METHODS = ["info", "warn", "error", "debug", "success", "fatal", "complete"]; + +const LOG_FILE = "/data/logs/backend.log"; +// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI colour codes before writing to disk +const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; + +let fileStream = null; +let lastOpenAttempt = 0; +// If the file can't be opened (eg. running outside the standard Docker image, or in +// CI without /data), don't retry on every single log call - but do retry periodically +// so a transient issue (disk full, permissions fixed later) recovers without a restart. +const REOPEN_COOLDOWN_MS = 30 * 1000; + +/** + * Lazily opens the backend log file for appending. If the directory isn't writable, + * file logging is silently disabled and console logging continues unaffected. + * + * @returns {import('node:fs').WriteStream|null} + */ +const getFileStream = () => { + if (fileStream) { + return fileStream; + } + + const now = Date.now(); + if (now - lastOpenAttempt < REOPEN_COOLDOWN_MS) { + return null; + } + lastOpenAttempt = now; + + try { + fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); + const stream = fs.createWriteStream(LOG_FILE, { flags: "a" }); + stream.on("error", () => { + fileStream = null; + }); + fileStream = stream; + } catch (_err) { + fileStream = null; + } + return fileStream; +}; + +/** + * Formats and appends a single log line to the backend log file. + * Never throws - a failure here must never take down the application. + * + * @param {String} level + * @param {String} scope + * @param {Array} args + */ +const writeToFile = (level, scope, args) => { + const stream = getFileStream(); + if (!stream) { + return; + } + + const message = args + .map((arg) => { + if (typeof arg === "string") return arg; + if (arg instanceof Error) return arg.stack || arg.message; + try { + return JSON.stringify(arg); + } catch (_err) { + return String(arg); + } + }) + .join(" ") + .replace(ANSI_PATTERN, ""); + + const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(7)} [${scope.trim()}] ${message}\n`; + stream.write(line); +}; + +/** + * Wraps a Signale instance so that every call to one of PERSISTED_METHODS is also + * appended to the backend log file, in addition to its normal console output. + * + * @param {Signale} instance + * @param {String} scope + * @returns {Signale} + */ +const withFileSink = (instance, scope) => { + for (const method of PERSISTED_METHODS) { + const original = instance[method].bind(instance); + instance[method] = (...args) => { + writeToFile(method, scope, args); + return original(...args); + }; + } + return instance; +}; + +const createLogger = (scope) => withFileSink(new signale.Signale({ scope, ...opts }), scope); + +const global = createLogger("Global "); +const migrate = createLogger("Migrate "); +const express = createLogger("Express "); +const access = createLogger("Access "); +const nginx = createLogger("Nginx "); +const ssl = createLogger("SSL "); +const certbot = createLogger("Certbot "); +const importer = createLogger("Importer "); +const setup = createLogger("Setup "); +const ipRanges = createLogger("IP Ranges"); +const remoteVersion = createLogger("Remote Version"); const debug = (logger, ...args) => { if (isDebugMode()) { diff --git a/backend/routes/logs.js b/backend/routes/logs.js new file mode 100644 index 0000000000..9088507b3a --- /dev/null +++ b/backend/routes/logs.js @@ -0,0 +1,106 @@ +import express from "express"; +import internalLogViewer from "../internal/log-viewer.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import validator from "../lib/validator/index.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * /api/logs/sources + */ +router + .route("/sources") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/logs/sources + * + * Lists the log sources available for the log viewer + */ + .get(async (req, res, next) => { + try { + const data = await internalLogViewer.listSources(res.locals.access); + res.status(200).send(data); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * /api/logs/tail + */ +router + .route("/tail") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/logs/tail + * + * Retrieve the last N lines of a log source + */ + .get(async (req, res, next) => { + try { + const data = await validator( + { + required: ["type"], + additionalProperties: false, + properties: { + type: { + type: "string", + enum: ["system", "letsencrypt", "host"], + }, + host_type: { + anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }], + }, + host_id: { + anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }], + }, + channel: { + anyOf: [{ type: "null" }, { type: "string", enum: ["access", "error"] }], + }, + lines: { + anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: 1000 }], + }, + level: { + anyOf: [ + { type: "null" }, + { type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] }, + ], + }, + search: { + anyOf: [{ type: "null" }, { type: "string", minLength: 1, maxLength: 200 }], + }, + }, + }, + { + type: req.query.type, + host_type: typeof req.query.host_type === "string" ? req.query.host_type : null, + host_id: typeof req.query.host_id !== "undefined" ? req.query.host_id : null, + channel: typeof req.query.channel === "string" ? req.query.channel : null, + lines: typeof req.query.lines !== "undefined" ? req.query.lines : null, + level: typeof req.query.level === "string" ? req.query.level : null, + search: typeof req.query.search === "string" ? req.query.search : null, + }, + ); + + const result = await internalLogViewer.tail(res.locals.access, data); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/main.js b/backend/routes/main.js index a308ea6179..2d719bb486 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -6,6 +6,7 @@ import pjson from "../package.json" with { type: "json" }; import { isSetup } from "../setup.js"; import auditLogRoutes from "./audit-log.js"; import ciRoutes from "./ci.js"; +import logsRoutes from "./logs.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; import deadHostsRoutes from "./nginx/dead_hosts.js"; @@ -50,6 +51,7 @@ router.use("/schema", schemaRoutes); router.use("/tokens", tokensRoutes); router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); +router.use("/logs", logsRoutes); router.use("/reports", reportsRoutes); router.use("/settings", settingsRoutes); router.use("/version", versionRoutes); diff --git a/backend/schema/components/log-sources-object.json b/backend/schema/components/log-sources-object.json new file mode 100644 index 0000000000..e077f1a0f9 --- /dev/null +++ b/backend/schema/components/log-sources-object.json @@ -0,0 +1,69 @@ +{ + "type": "object", + "description": "Available log sources for the log viewer", + "required": ["system", "letsencrypt", "hosts"], + "additionalProperties": false, + "properties": { + "system": { + "type": "object", + "properties": { + "label": { + "type": "string", + "example": "System" + } + } + }, + "letsencrypt": { + "type": "object", + "properties": { + "label": { + "type": "string", + "example": "Let's Encrypt" + } + } + }, + "hosts": { + "type": "object", + "required": ["proxy", "redirection", "dead", "stream"], + "additionalProperties": false, + "properties": { + "proxy": { + "$ref": "#/$defs/host-option-list" + }, + "redirection": { + "$ref": "#/$defs/host-option-list" + }, + "dead": { + "$ref": "#/$defs/host-option-list" + }, + "stream": { + "$ref": "#/$defs/host-option-list" + } + }, + "example": { + "proxy": [{ "id": 1, "label": "example.com" }], + "redirection": [], + "dead": [], + "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }] + } + } + }, + "$defs": { + "host-option-list": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { + "$ref": "../common.json#/properties/id" + }, + "label": { + "type": "string", + "example": "example.com" + } + } + } + } + } +} diff --git a/backend/schema/components/log-tail-object.json b/backend/schema/components/log-tail-object.json new file mode 100644 index 0000000000..a897f08fcf --- /dev/null +++ b/backend/schema/components/log-tail-object.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "description": "The last N lines of a log source", + "required": ["lines", "size", "truncated", "exists"], + "additionalProperties": false, + "properties": { + "lines": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."] + }, + "size": { + "type": "integer", + "description": "Size in bytes of the underlying log file", + "example": 4096 + }, + "truncated": { + "type": "boolean", + "description": "True when the file is larger than the maximum amount of data scanned per request", + "example": false + }, + "exists": { + "type": "boolean", + "description": "False when the underlying log file does not exist yet", + "example": true + } + } +} diff --git a/backend/schema/paths/logs/sources/get.json b/backend/schema/paths/logs/sources/get.json new file mode 100644 index 0000000000..2556986999 --- /dev/null +++ b/backend/schema/paths/logs/sources/get.json @@ -0,0 +1,36 @@ +{ + "operationId": "getLogSources", + "summary": "Get available log sources", + "tags": ["logs"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "system": { "label": "System" }, + "letsencrypt": { "label": "Let's Encrypt" }, + "hosts": { + "proxy": [{ "id": 1, "label": "example.com" }], + "redirection": [], + "dead": [], + "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }] + } + } + } + }, + "schema": { + "$ref": "../../../components/log-sources-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/logs/tail/get.json b/backend/schema/paths/logs/tail/get.json new file mode 100644 index 0000000000..2e99ede574 --- /dev/null +++ b/backend/schema/paths/logs/tail/get.json @@ -0,0 +1,100 @@ +{ + "operationId": "getLogTail", + "summary": "Get the last N lines of a log source", + "tags": ["logs"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "query", + "name": "type", + "required": true, + "description": "Which log source to read", + "schema": { + "type": "string", + "enum": ["system", "letsencrypt", "host"] + } + }, + { + "in": "query", + "name": "host_type", + "description": "Required when type=host", + "schema": { + "type": "string", + "enum": ["proxy", "redirection", "dead", "stream"] + } + }, + { + "in": "query", + "name": "host_id", + "description": "Required when type=host", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "channel", + "description": "Required when type=host - which log file to read (not to be confused with host_type=stream)", + "schema": { + "type": "string", + "enum": ["access", "error"] + } + }, + { + "in": "query", + "name": "lines", + "description": "Number of lines to return from the end of the file", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + }, + { + "in": "query", + "name": "level", + "description": "Only applicable to type=system", + "schema": { + "type": "string", + "enum": ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] + } + }, + { + "in": "query", + "name": "search", + "description": "Case-insensitive plain-text search", + "schema": { + "type": "string", + "maxLength": 200 + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "lines": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."], + "size": 4096, + "truncated": false, + "exists": true + } + } + }, + "schema": { + "$ref": "../../../components/log-tail-object.json" + } + } + } + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..fd0dc33f51 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -24,6 +24,10 @@ "name": "audit-log", "description": "Endpoints related to Audit Logs" }, + { + "name": "logs", + "description": "Endpoints for viewing system, Let's Encrypt and per-host logs" + }, { "name": "access-lists", "description": "Endpoints related to Access Lists" @@ -81,6 +85,16 @@ "$ref": "./paths/audit-log/id/get.json" } }, + "/logs/sources": { + "get": { + "$ref": "./paths/logs/sources/get.json" + } + }, + "/logs/tail": { + "get": { + "$ref": "./paths/logs/tail/get.json" + } + }, "/nginx/access-lists": { "get": { "$ref": "./paths/nginx/access-lists/get.json" diff --git a/docker/rootfs/etc/logrotate.d/nginx-proxy-manager b/docker/rootfs/etc/logrotate.d/nginx-proxy-manager index de9772971e..3e42324c77 100644 --- a/docker/rootfs/etc/logrotate.d/nginx-proxy-manager +++ b/docker/rootfs/etc/logrotate.d/nginx-proxy-manager @@ -25,3 +25,13 @@ kill -USR1 `cat /run/nginx/nginx.pid 2>/dev/null` 2>/dev/null || true endscript } + +/data/logs/backend.log { + su npm npm + size 10M + rotate 5 + missingok + notifempty + compress + copytruncate +} diff --git a/frontend/.gitignore b/frontend/.gitignore index a9b91bc1a0..50c1dc047e 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,7 +1,7 @@ src/locale/lang # Logs -logs +/logs/ *.log npm-debug.log* yarn-debug.log* diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894f..e67ba00db1 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -20,6 +20,7 @@ const Settings = lazy(() => import("src/pages/Settings")); const Certificates = lazy(() => import("src/pages/Certificates")); const Access = lazy(() => import("src/pages/Access")); const AuditLog = lazy(() => import("src/pages/AuditLog")); +const Logs = lazy(() => import("src/pages/Logs")); const Users = lazy(() => import("src/pages/Users")); const ProxyHosts = lazy(() => import("src/pages/Nginx/ProxyHosts")); const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts")); @@ -64,6 +65,7 @@ function Router() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/backend/getLogSources.ts b/frontend/src/api/backend/getLogSources.ts new file mode 100644 index 0000000000..4bec700024 --- /dev/null +++ b/frontend/src/api/backend/getLogSources.ts @@ -0,0 +1,8 @@ +import * as api from "./base"; +import type { LogSources } from "./models"; + +export async function getLogSources(): Promise { + return await api.get({ + url: "/logs/sources", + }); +} diff --git a/frontend/src/api/backend/getLogTail.ts b/frontend/src/api/backend/getLogTail.ts new file mode 100644 index 0000000000..899282b326 --- /dev/null +++ b/frontend/src/api/backend/getLogTail.ts @@ -0,0 +1,19 @@ +import * as api from "./base"; +import type { LogChannel, LogHostType, LogSourceType, LogTail } from "./models"; + +export interface GetLogTailParams { + type: LogSourceType; + hostType?: LogHostType; + hostId?: number; + channel?: LogChannel; + lines?: number; + level?: string; + search?: string; +} + +export async function getLogTail(params: GetLogTailParams): Promise { + return await api.get({ + url: "/logs/tail", + params: { ...params }, + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..99e682294a 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -26,6 +26,8 @@ export * from "./getDeadHost"; export * from "./getDeadHosts"; export * from "./getHealth"; export * from "./getHostsReport"; +export * from "./getLogSources"; +export * from "./getLogTail"; export * from "./getProxyHost"; export * from "./getProxyHosts"; export * from "./getRedirectionHost"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..3febec790a 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -44,6 +44,30 @@ export interface AuditLog { user?: User; } +export type LogSourceType = "system" | "letsencrypt" | "host"; +export type LogHostType = "proxy" | "redirection" | "dead" | "stream"; +// Which of the two files nginx writes per host - named "channel", not "stream", +// to avoid confusion with the "stream" LogHostType (TCP/UDP stream hosts). +export type LogChannel = "access" | "error"; + +export interface LogHostOption { + id: number; + label: string; +} + +export interface LogSources { + system: { label: string }; + letsencrypt: { label: string }; + hosts: Record; +} + +export interface LogTail { + lines: string[]; + size: number; + truncated: boolean; + exists: boolean; +} + export interface AccessList { id?: number; createdOn?: string; diff --git a/frontend/src/components/SiteMenu.tsx b/frontend/src/components/SiteMenu.tsx index 565fa01bf9..c7274751ae 100644 --- a/frontend/src/components/SiteMenu.tsx +++ b/frontend/src/components/SiteMenu.tsx @@ -1,6 +1,7 @@ import { IconBook, IconDeviceDesktop, + IconFileText, IconHome, IconLock, IconSettings, @@ -95,6 +96,12 @@ const menuItems: MenuItem[] = [ label: "auditlogs", permissionSection: ADMIN, }, + { + to: "/logs", + icon: IconFileText, + label: "logs", + permissionSection: ADMIN, + }, { to: "/settings", icon: IconSettings, diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 744190ade1..03dd9881a1 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -10,6 +10,8 @@ export * from "./useDeadHosts"; export * from "./useDnsProviders"; export * from "./useHealth"; export * from "./useHostReport"; +export * from "./useLogSources"; +export * from "./useLogTail"; export * from "./useProxyHost"; export * from "./useProxyHosts"; export * from "./useRedirectionHost"; diff --git a/frontend/src/hooks/useLogSources.ts b/frontend/src/hooks/useLogSources.ts new file mode 100644 index 0000000000..3f77bbd1ef --- /dev/null +++ b/frontend/src/hooks/useLogSources.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { getLogSources, type LogSources } from "src/api/backend"; + +const fetchLogSources = () => getLogSources(); + +const useLogSources = (options = {}) => { + return useQuery({ + queryKey: ["log-sources"], + queryFn: fetchLogSources, + staleTime: 30 * 1000, + ...options, + }); +}; + +export { fetchLogSources, useLogSources }; diff --git a/frontend/src/hooks/useLogTail.ts b/frontend/src/hooks/useLogTail.ts new file mode 100644 index 0000000000..d817dd0c11 --- /dev/null +++ b/frontend/src/hooks/useLogTail.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { type GetLogTailParams, getLogTail, type LogTail } from "src/api/backend"; + +const POLL_INTERVAL_MS = 5000; + +interface UseLogTailOptions extends GetLogTailParams { + live?: boolean; +} + +// Polls for new log lines while `live` is true. React Query only runs the interval +// while the tab is focused (refetchIntervalInBackground defaults to false), so an +// idle/backgrounded browser tab never generates load. +const useLogTail = ({ live = true, ...params }: UseLogTailOptions) => { + const enabled = params.type === "host" ? Boolean(params.hostType && params.hostId && params.channel) : true; + + return useQuery({ + queryKey: ["log-tail", params], + queryFn: () => getLogTail(params), + enabled, + refetchInterval: live ? POLL_INTERVAL_MS : false, + placeholderData: (previousData) => previousData, + }); +}; + +export { useLogTail }; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 88fdd724ed..2626673dfc 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -488,6 +488,51 @@ "login.title": { "defaultMessage": "Login to your account" }, + "logs": { + "defaultMessage": "Logs" + }, + "logs.channel.access": { + "defaultMessage": "Access" + }, + "logs.channel.error": { + "defaultMessage": "Error" + }, + "logs.download": { + "defaultMessage": "Download" + }, + "logs.empty": { + "defaultMessage": "No log entries yet." + }, + "logs.level": { + "defaultMessage": "Level" + }, + "logs.level.all": { + "defaultMessage": "All Levels" + }, + "logs.lines": { + "defaultMessage": "Lines" + }, + "logs.live": { + "defaultMessage": "Live" + }, + "logs.paused": { + "defaultMessage": "Paused" + }, + "logs.refresh": { + "defaultMessage": "Refresh" + }, + "logs.search-placeholder": { + "defaultMessage": "Search log…" + }, + "logs.source": { + "defaultMessage": "Source" + }, + "logs.source.system": { + "defaultMessage": "System" + }, + "logs.truncated-warning": { + "defaultMessage": "Only the most recent portion of this file is shown." + }, "nginx-config.label": { "defaultMessage": "Custom Nginx Configuration" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index a5b5393b3e..9fd0f74d17 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -488,6 +488,51 @@ "login.title": { "defaultMessage": "Logi kontole sisse" }, + "logs": { + "defaultMessage": "Logid" + }, + "logs.channel.access": { + "defaultMessage": "Juurdepääs" + }, + "logs.channel.error": { + "defaultMessage": "Viga" + }, + "logs.download": { + "defaultMessage": "Laadi alla" + }, + "logs.empty": { + "defaultMessage": "Logikirjeid pole veel." + }, + "logs.level": { + "defaultMessage": "Tase" + }, + "logs.level.all": { + "defaultMessage": "Kõik tasemed" + }, + "logs.lines": { + "defaultMessage": "Read" + }, + "logs.live": { + "defaultMessage": "Reaalajas" + }, + "logs.paused": { + "defaultMessage": "Peatatud" + }, + "logs.refresh": { + "defaultMessage": "Värskenda" + }, + "logs.search-placeholder": { + "defaultMessage": "Otsi logist…" + }, + "logs.source": { + "defaultMessage": "Allikas" + }, + "logs.source.system": { + "defaultMessage": "Süsteem" + }, + "logs.truncated-warning": { + "defaultMessage": "Kuvatakse ainult faili kõige uuem osa." + }, "nginx-config.label": { "defaultMessage": "Kohandatud Nginx seadistus" }, diff --git a/frontend/src/pages/Logs/LogLines.tsx b/frontend/src/pages/Logs/LogLines.tsx new file mode 100644 index 0000000000..7e1d4763ca --- /dev/null +++ b/frontend/src/pages/Logs/LogLines.tsx @@ -0,0 +1,76 @@ +import cn from "classnames"; +import { useEffect, useRef } from "react"; +import { T } from "src/locale"; +import styles from "./LogViewer.module.css"; + +const LEVEL_PATTERN = /\b(ERROR|FATAL|WARN|SUCCESS|COMPLETE|DEBUG|INFO)\b/; + +const levelClassName = (line: string): string | undefined => { + const match = line.match(LEVEL_PATTERN); + if (!match) { + return undefined; + } + switch (match[1]) { + case "ERROR": + case "FATAL": + return "text-danger"; + case "WARN": + return "text-warning"; + case "SUCCESS": + case "COMPLETE": + return "text-success"; + case "DEBUG": + return "text-secondary"; + default: + return undefined; + } +}; + +// How close to the bottom (in pixels) the user has to be for auto-scroll to keep +// following new lines. Scrolling further up than this to read history disables it, +// so newly polled lines never yank the viewport away from what's being read. +const AUTO_SCROLL_THRESHOLD_PX = 40; + +interface Props { + lines: string[]; +} + +export default function LogLines({ lines }: Props) { + const containerRef = useRef(null); + const stickToBottomRef = useRef(true); + + const handleScroll = () => { + const el = containerRef.current; + if (!el) { + return; + } + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX; + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: lines is a re-scroll trigger, not read in the body + useEffect(() => { + const el = containerRef.current; + if (el && stickToBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [lines]); + + if (!lines.length) { + return ( +
+ +
+ ); + } + + return ( +
+			{lines.map((line, idx) => (
+				
+					{line}
+				
+			))}
+		
+ ); +} diff --git a/frontend/src/pages/Logs/LogViewer.module.css b/frontend/src/pages/Logs/LogViewer.module.css new file mode 100644 index 0000000000..09cb1c88cf --- /dev/null +++ b/frontend/src/pages/Logs/LogViewer.module.css @@ -0,0 +1,15 @@ +.logBox { + height: 65vh; + overflow-y: auto; + padding: 0.75rem 1rem; + margin: 0; + font-family: var(--tblr-font-monospace, ui-monospace, monospace); + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; +} + +.logLine { + display: block; +} diff --git a/frontend/src/pages/Logs/LogViewer.tsx b/frontend/src/pages/Logs/LogViewer.tsx new file mode 100644 index 0000000000..728420c129 --- /dev/null +++ b/frontend/src/pages/Logs/LogViewer.tsx @@ -0,0 +1,249 @@ +import { useEffect, useMemo, useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import type { LogChannel, LogHostType, LogSourceType } from "src/api/backend"; +import { Loading } from "src/components"; +import { useLogSources, useLogTail } from "src/hooks"; +import { T, intl } from "src/locale"; +import LogLines from "./LogLines"; + +const LINES_OPTIONS = [100, 200, 500, 1000]; +const LEVEL_OPTIONS = ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"]; +const SEARCH_DEBOUNCE_MS = 300; + +const HOST_TYPES: { type: LogHostType; labelId: string }[] = [ + { type: "proxy", labelId: "proxy-hosts" }, + { type: "redirection", labelId: "redirection-hosts" }, + { type: "dead", labelId: "dead-hosts" }, + { type: "stream", labelId: "streams" }, +]; + +interface Selection { + type: LogSourceType; + hostType?: LogHostType; + hostId?: number; +} + +const encodeSelection = (selection: Selection): string => { + if (selection.type === "host") { + return `host:${selection.hostType}:${selection.hostId}`; + } + return selection.type; +}; + +const decodeSelection = (value: string): Selection => { + if (value === "system" || value === "letsencrypt") { + return { type: value }; + } + const [, hostType, hostId] = value.split(":"); + return { type: "host", hostType: hostType as LogHostType, hostId: Number(hostId) }; +}; + +const formatBytes = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KB", "MB", "GB"]; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + return `${value.toFixed(1)} ${units[unitIndex]}`; +}; + +export default function LogViewer() { + const sourcesQuery = useLogSources(); + + const [selection, setSelection] = useState({ type: "system" }); + const [channel, setChannel] = useState("access"); + const [level, setLevel] = useState(""); + const [lines, setLines] = useState(LINES_OPTIONS[1]); + const [live, setLive] = useState(true); + const [searchInput, setSearchInput] = useState(""); + const [search, setSearch] = useState(""); + + useEffect(() => { + const handle = setTimeout(() => setSearch(searchInput.trim()), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [searchInput]); + + const tailQuery = useLogTail({ + type: selection.type, + hostType: selection.hostType, + hostId: selection.hostId, + channel: selection.type === "host" ? channel : undefined, + lines, + level: selection.type === "system" && level ? level : undefined, + search: search || undefined, + live, + }); + + const hostGroups = useMemo(() => { + if (!sourcesQuery.data) { + return []; + } + return HOST_TYPES.map(({ type, labelId }) => ({ + type, + labelId, + options: sourcesQuery.data.hosts[type] || [], + })).filter((group) => group.options.length > 0); + }, [sourcesQuery.data]); + + const handleDownload = () => { + const content = (tailQuery.data?.lines || []).join("\n"); + const blob = new Blob([content], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${encodeSelection(selection).replace(/:/g, "-")}.log`; + a.click(); + window.URL.revokeObjectURL(url); + }; + + return ( +
+
+
+
+
+

+ +

+
+ {typeof tailQuery.data?.size === "number" && ( +
+ {formatBytes(tailQuery.data.size)} +
+ )} +
+ +
+ {selection.type === "host" && ( +
+
+ + +
+
+ )} + {selection.type === "system" && ( +
+ +
+ )} +
+ setSearchInput(e.target.value)} + /> +
+
+ +
+
+ + + +
+
+
+ + {tailQuery.isError && ( +
+ {tailQuery.error?.message || "Unknown error"} +
+ )} + + {tailQuery.data?.truncated && ( +
+ + + +
+ )} + + {sourcesQuery.isLoading || (tailQuery.isLoading && !tailQuery.data) ? ( +
+ +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/pages/Logs/index.tsx b/frontend/src/pages/Logs/index.tsx new file mode 100644 index 0000000000..eb72b8e112 --- /dev/null +++ b/frontend/src/pages/Logs/index.tsx @@ -0,0 +1,13 @@ +import { HasPermission } from "src/components"; +import { ADMIN, VIEW } from "src/modules/Permissions"; +import LogViewer from "./LogViewer"; + +const Logs = () => { + return ( + + + + ); +}; + +export default Logs; From f47d85da9e4f07116748d13e3d9d6ad99a6c0cac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:56:05 +0000 Subject: [PATCH 17/55] build(deps-dev): bump the dev-minor-updates group across 1 directory with 5 updates Bumps the dev-minor-updates group with 5 updates in the /frontend directory: | Package | From | To | | --- | --- | --- | | [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.101.4` | `5.103.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.6.2` | | [happy-dom](https://github.com/capricorn86/happy-dom) | `20.11.6` | `20.14.5` | | [sass](https://github.com/sass/dart-sass) | `1.103.1` | `1.104.1` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.2` | `8.3.0` | Updates `@tanstack/react-query-devtools` from 5.101.4 to 5.103.1 - [Release notes](https://github.com/TanStack/query/releases) - [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md) - [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.103.1/packages/react-query-devtools) Updates `@types/node` from 26.2.0 to 26.6.2 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `happy-dom` from 20.11.6 to 20.14.5 - [Release notes](https://github.com/capricorn86/happy-dom/releases) - [Commits](https://github.com/capricorn86/happy-dom/compare/v20.11.6...v20.14.5) Updates `sass` from 1.103.1 to 1.104.1 - [Release notes](https://github.com/sass/dart-sass/releases) - [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md) - [Commits](https://github.com/sass/dart-sass/compare/1.103.1...1.104.1) Updates `vite` from 8.2.2 to 8.3.0 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite) --- updated-dependencies: - dependency-name: "@tanstack/react-query-devtools" dependency-version: 5.102.8 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@types/node" dependency-version: 26.5.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: happy-dom dependency-version: 20.14.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sass dependency-version: 1.104.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: vite dependency-version: 8.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] --- frontend/package.json | 10 +- frontend/yarn.lock | 494 ++++++++++++++++++++++++++++++------------ 2 files changed, 361 insertions(+), 143 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 6b2fbf9194..e2cc7ee64b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,24 +43,24 @@ "devDependencies": { "@biomejs/biome": "^2.5.10", "@formatjs/cli": "^6.16.30", - "@tanstack/react-query-devtools": "^5.101.4", + "@tanstack/react-query-devtools": "^5.103.1", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@types/country-flag-icons": "^1.2.2", "@types/humps": "^2.0.6", - "@types/node": "^26.2.0", + "@types/node": "^26.6.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/react-table": "^7.7.20", "@vitejs/plugin-react": "^6.1.1", - "happy-dom": "^20.11.6", + "happy-dom": "^20.14.5", "postcss": "^8.5.28", "postcss-simple-vars": "^7.0.1", - "sass": "^1.103.1", + "sass": "^1.104.1", "tmp": "^0.2.7", "typescript": "7.0.2", - "vite": "^8.2.2", + "vite": "^8.3.0", "vite-plugin-checker": "^0.14.5", "vitest": "^4.1.11" } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d32606e873..eb0aa21f9f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -146,6 +146,13 @@ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz#3bdeaf5856f21aec6b6c50a7bca5464b9cda5623" integrity sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA== +"@corvu/utils@~0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@corvu/utils/-/utils-0.4.2.tgz#ae867221297fd1e362efe41f081c42d7c746ff61" + integrity sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA== + dependencies: + "@floating-ui/dom" "^1.6.11" + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" @@ -241,7 +248,7 @@ dependencies: "@floating-ui/utils" "^0.2.12" -"@floating-ui/dom@^1.0.1": +"@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.5.1", "@floating-ui/dom@^1.6.11": version "1.8.0" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.8.0.tgz#8a20e6facbe2456afdbeb6c8b968a72df689cf63" integrity sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg== @@ -329,6 +336,13 @@ dependencies: "@swc/helpers" "^0.5.0" +"@internationalized/number@^3.2.1": + version "3.6.8" + resolved "https://registry.yarnpkg.com/@internationalized/number/-/number-3.6.8.tgz#aaa3e16fb9d64a8d7f130ccf848c626914dfdf9e" + integrity sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig== + dependencies: + "@swc/helpers" "^0.5.0" + "@internationalized/number@^3.6.7": version "3.6.7" resolved "https://registry.yarnpkg.com/@internationalized/number/-/number-3.6.7.tgz#5a0a8fa413b5f8679a59dcf37e2a74dc508b8371" @@ -376,10 +390,36 @@ dependencies: jsbi "^4.3.0" -"@oxc-project/types@=0.146.0": - version "0.146.0" - resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.146.0.tgz#d57a2591abbf1f6e50981b07ee24ab269530d87a" - integrity sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA== +"@kobalte/core@^0.13.4": + version "0.13.14" + resolved "https://registry.yarnpkg.com/@kobalte/core/-/core-0.13.14.tgz#6d46ada9a427e07f73fb61123a03220277a793d7" + integrity sha512-SBVB1lnvYbYt2x3t7fG0lo5fGLEbHQ6PII3NDPva8vD5e2v53On84ZVQiuIk0XI/55R4GQhden7Yem+R/XR8uQ== + dependencies: + "@floating-ui/dom" "^1.5.1" + "@internationalized/number" "^3.2.1" + "@kobalte/utils" "^0.9.2" + "@solid-primitives/props" "^3.1.8" + "@solid-primitives/resize-observer" "^2.0.26" + solid-presence "^0.2.0" + solid-prevent-scroll "^0.1.11" + +"@kobalte/utils@^0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@kobalte/utils/-/utils-0.9.2.tgz#cb4ccbbd93efda1f2e3602ffeec7fd8b58b69e7b" + integrity sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow== + dependencies: + "@solid-primitives/event-listener" "^2.2.14" + "@solid-primitives/keyed" "^1.2.0" + "@solid-primitives/map" "^0.4.7" + "@solid-primitives/media" "^2.2.4" + "@solid-primitives/props" "^3.1.8" + "@solid-primitives/refs" "^1.0.5" + "@solid-primitives/utils" "^6.2.1" + +"@oxc-project/types@=0.150.0": + version "0.150.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.150.0.tgz#e9be0840114655016b16f1c05ddaaa01df5b8853" + integrity sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw== "@parcel/watcher-android-arm64@2.6.0": version "2.6.0" @@ -511,86 +551,177 @@ uncontrollable "^8.0.4" warning "^4.0.3" -"@rolldown/binding-android-arm-eabi@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz#165b80910de7cd33f772d5b7b045b259acb7420c" - integrity sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA== - -"@rolldown/binding-android-arm64@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz#5ed74d4b8fa56c68eb1aeb81d0d207a85b6de05c" - integrity sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig== - -"@rolldown/binding-darwin-arm64@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz#6f27c7060e58ca03061fa7d50f9dc409bc377fe1" - integrity sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww== - -"@rolldown/binding-darwin-x64@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz#74ab897d134ede4072fdc6108f00193e6167ee28" - integrity sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A== - -"@rolldown/binding-freebsd-x64@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz#7d337f9ae4b739674e1938747118ab0676c8ef8a" - integrity sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw== - -"@rolldown/binding-linux-arm-gnueabihf@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz#a8195dc1091ade0f912b0613ef6500941e5615f4" - integrity sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg== - -"@rolldown/binding-linux-arm64-gnu@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz#40fbbb97072b1acaa3e0e8fe9774fe524e860bba" - integrity sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA== - -"@rolldown/binding-linux-arm64-musl@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz#3cfe8b0f7c13de29dace9a9fc6c03081164176ab" - integrity sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA== - -"@rolldown/binding-linux-ppc64-gnu@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz#3bd890d96ae29f93718aa142ed0982f2ee2978ad" - integrity sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA== - -"@rolldown/binding-linux-s390x-gnu@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz#0959a0d5af22741d5787918e27aef70dc8602804" - integrity sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ== - -"@rolldown/binding-linux-x64-gnu@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz#7baa94e826328ac6f457d9e1abacffa0353e8d22" - integrity sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ== - -"@rolldown/binding-linux-x64-musl@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz#5f37597eaf0e22313d1e3d4be7d1be1fd332904e" - integrity sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA== - -"@rolldown/binding-openharmony-arm64@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz#d096567af3f738cbe6aa858ab0a01aae9f357ef4" - integrity sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw== - -"@rolldown/binding-win32-arm64-msvc@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz#d53b911aa4e6b547b789c07747fabab2ac8c237d" - integrity sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw== - -"@rolldown/binding-win32-x64-msvc@1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz#7bbd08cfda6a98de9b756472c772a36ebb7229bc" - integrity sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw== +"@rolldown/binding-android-arm-eabi@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz#b38df5a6997454696207ac16b322a3ac1ff79e16" + integrity sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw== + +"@rolldown/binding-android-arm64@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz#bc2bf135c1e787f94b29bc321c1db0bd2884b5a7" + integrity sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg== + +"@rolldown/binding-darwin-arm64@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz#052049707ccc4cfe0b035aa8c37c4e55523eecbb" + integrity sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw== + +"@rolldown/binding-darwin-x64@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz#ea67496811e4e1c2f76d5647f248073e204f5462" + integrity sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw== + +"@rolldown/binding-freebsd-x64@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz#4b4a605f8c24a7235afb5da9b4b8bc8974446bb5" + integrity sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g== + +"@rolldown/binding-linux-arm-gnueabihf@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz#a36c6961fb24b86176463559dcd0edbe48ea74f1" + integrity sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw== + +"@rolldown/binding-linux-arm64-gnu@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz#7b43488618dd6f9d392e9adaaffd4d7f4bb785ce" + integrity sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw== + +"@rolldown/binding-linux-arm64-musl@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz#8168a2e2ce7d9e28847f3019c98abf76c0ec5fe2" + integrity sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA== + +"@rolldown/binding-linux-ppc64-gnu@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz#8f52aac40788feac33056f111176a7bf8fbc297a" + integrity sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw== + +"@rolldown/binding-linux-s390x-gnu@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz#80cdfa507fd87bc37c47c5a16ca578f619ee3a69" + integrity sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ== + +"@rolldown/binding-linux-x64-gnu@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz#c2f89cdf3b223b2ba3cd4c0fdc760b360c02edf5" + integrity sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q== + +"@rolldown/binding-linux-x64-musl@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz#3fbb6bce4c8a8260cb63fc2dba182c8649930c6c" + integrity sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ== + +"@rolldown/binding-openharmony-arm64@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz#c8cd18d7fd14222e2068cd0640bba88d2cd710f8" + integrity sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg== + +"@rolldown/binding-win32-arm64-msvc@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz#cff9ed3be36979d7d77306bf9dca1a27ed17c1f0" + integrity sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww== + +"@rolldown/binding-win32-x64-msvc@1.2.9": + version "1.2.9" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz#6e6a3a7ac86eb46bed61e685a9e7cccfacc21b7a" + integrity sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g== "@rolldown/pluginutils@^1.0.0", "@rolldown/pluginutils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== +"@solid-primitives/event-listener@^2.2.14", "@solid-primitives/event-listener@^2.4.6": + version "2.4.6" + resolved "https://registry.yarnpkg.com/@solid-primitives/event-listener/-/event-listener-2.4.6.tgz#95704ae633b92551334e48399ad8785aa7f9f426" + integrity sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw== + dependencies: + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/keyed@^1.2.0", "@solid-primitives/keyed@^1.2.2": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@solid-primitives/keyed/-/keyed-1.5.3.tgz#df60e7a079cc99ef80d22e4c03bb80eabc106392" + integrity sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA== + +"@solid-primitives/map@^0.4.7": + version "0.4.13" + resolved "https://registry.yarnpkg.com/@solid-primitives/map/-/map-0.4.13.tgz#f0096c53ad3405fde444a738f4b1adb0f402ea16" + integrity sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew== + dependencies: + "@solid-primitives/trigger" "^1.1.0" + +"@solid-primitives/media@^2.2.4": + version "2.3.6" + resolved "https://registry.yarnpkg.com/@solid-primitives/media/-/media-2.3.6.tgz#9c4c64410ab8a066871d6a45ad69529289f57b3e" + integrity sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw== + dependencies: + "@solid-primitives/event-listener" "^2.4.6" + "@solid-primitives/rootless" "^1.5.4" + "@solid-primitives/static-store" "^0.1.4" + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/props@^3.1.8": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@solid-primitives/props/-/props-3.2.4.tgz#517b387fde97e85d701f8d72866f5480a23ec4cc" + integrity sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA== + dependencies: + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/refs@^1.0.5": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@solid-primitives/refs/-/refs-1.1.4.tgz#d8aef55a51d5141527b6ad151f7c98a207d3b644" + integrity sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A== + dependencies: + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/resize-observer@^2.0.26": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@solid-primitives/resize-observer/-/resize-observer-2.2.0.tgz#f6d76c0f765bb2008117c2e7504eb1707ef1268c" + integrity sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww== + dependencies: + "@solid-primitives/event-listener" "^2.4.6" + "@solid-primitives/rootless" "^1.5.4" + "@solid-primitives/static-store" "^0.1.4" + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/rootless@^1.5.4": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@solid-primitives/rootless/-/rootless-1.5.4.tgz#10ee4112bcadf51a57c67f0bf728e1651a077233" + integrity sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g== + dependencies: + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/static-store@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@solid-primitives/static-store/-/static-store-0.1.4.tgz#fe6792b4268dd9b06374b28b1d9fc6dfb8da364d" + integrity sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA== + dependencies: + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/storage@^1.3.11": + version "1.3.11" + resolved "https://registry.yarnpkg.com/@solid-primitives/storage/-/storage-1.3.11.tgz#2d273f3ab9e76e8819459fd9f0fccce8b09ebedb" + integrity sha512-PpQWR3TaTxHIJFbI9ZssYTM4Aa67g1vJIgps4TPhcXzHqqomrPAIveFC2FG7SDQoi9YQia8FVBjigELziJpfIg== + dependencies: + "@solid-primitives/utils" "^6.2.0" + +"@solid-primitives/transition-group@^1.0.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@solid-primitives/transition-group/-/transition-group-1.1.2.tgz#be9af05871a7ca6323277f9782f4aeb20cb7f73f" + integrity sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA== + +"@solid-primitives/trigger@^1.1.0": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@solid-primitives/trigger/-/trigger-1.2.4.tgz#5811053de6c650eb1aed504f77086433beefe208" + integrity sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ== + dependencies: + "@solid-primitives/utils" "^6.4.1" + +"@solid-primitives/utils@^6.2.0", "@solid-primitives/utils@^6.2.1", "@solid-primitives/utils@^6.4.1": + version "6.4.1" + resolved "https://registry.yarnpkg.com/@solid-primitives/utils/-/utils-6.4.1.tgz#aaa9006ac9dfa7b99b1060ab78669a8723ab2962" + integrity sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg== + "@standard-schema/spec@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" @@ -622,22 +753,46 @@ resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.46.0.tgz#28ba3f4895715863fdd78b49a5617764c9557fe5" integrity sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A== +"@tanstack/match-sorter-utils@^8.19.4": + version "8.19.4" + resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz#dacf772b5d94f4684f10dbeb2518cf72dccab8a5" + integrity sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg== + dependencies: + remove-accents "0.5.0" + "@tanstack/query-core@5.102.8": version "5.102.8" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.102.8.tgz#c55c31b4f99124054805ce8ec8137c84108f3252" integrity sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg== -"@tanstack/query-devtools@5.101.4": - version "5.101.4" - resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz#4fa86fd8f8a1e974923faf1987d93cf543b8305a" - integrity sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA== +"@tanstack/query-core@5.103.1": + version "5.103.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.103.1.tgz#57c1db20679b98e793a1fe8e4a55a0619d2d681f" + integrity sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA== + +"@tanstack/query-devtools@5.103.1": + version "5.103.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.103.1.tgz#4691fc78c33e98304e6b3597d8ac67f3cd2b5ef8" + integrity sha512-xMOcnULRPEjww0A/0RnweNmcZ5hesLjTekqf8SHxf+BJH729qz6sUu5TsOWFo7J4fu3OR50MeuJUsM3BN78Kfw== + dependencies: + "@kobalte/core" "^0.13.4" + "@solid-primitives/keyed" "^1.2.2" + "@solid-primitives/resize-observer" "^2.0.26" + "@solid-primitives/storage" "^1.3.11" + "@tanstack/match-sorter-utils" "^8.19.4" + "@tanstack/query-core" "5.103.1" + clsx "^2.1.1" + goober "^2.1.16" + solid-js "^1.9.7" + solid-transition-group "^0.2.3" + superjson "^2.2.2" -"@tanstack/react-query-devtools@^5.101.4": - version "5.101.4" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.101.4.tgz#adbac799a9b70d67c52e4a483450005d68c9a127" - integrity sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA== +"@tanstack/react-query-devtools@^5.103.1": + version "5.103.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.103.1.tgz#b3548301405de0b1205bb259f12aba6cbf3266ef" + integrity sha512-l6OzwPbDCnuim8w9McFs3dC/rdXTMZBIBFMVrXzNuhUEzWheldubOu+nKRDtPf+1nLRuwavb3We+nNJq/0vV6w== dependencies: - "@tanstack/query-devtools" "5.101.4" + "@tanstack/query-devtools" "5.103.1" "@tanstack/react-query@^5.102.8": version "5.102.8" @@ -787,12 +942,12 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== -"@types/node@*", "@types/node@>=20.0.0", "@types/node@^26.2.0": - version "26.2.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-26.2.0.tgz#5a4875a862fda8fdc57de8faa579bb81ecba1685" - integrity sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg== +"@types/node@*", "@types/node@>=20.0.0", "@types/node@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.2.tgz#11bfb8e00bafe728d2113e474a7637becc9ab279" + integrity sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g== dependencies: - undici-types "~8.3.0" + undici-types "~8.9.0" "@types/parse-json@^4.0.0": version "4.0.2" @@ -1188,6 +1343,11 @@ cookie@^1.0.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== +copy-anything@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-4.1.1.tgz#2734bcffa1f01f8e2afb2232f1ee31cd5fcc6834" + integrity sha512-AoT6Imdr98feSpFfmFwTFN73ccdr7uFPf27cBCgYvyyRyn1BzLRxMvrHNmwXO5LJMddRy4Rdhw2b1h7vSMKsEw== + cosmiconfig@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -1209,7 +1369,7 @@ css.escape@^1.5.1: resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== -csstype@^3.0.2, csstype@^3.2.2: +csstype@^3.0.2, csstype@^3.1.0, csstype@^3.2.2: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== @@ -1389,15 +1549,20 @@ generate-password-browser@^1.1.0: buffer "^6.0.3" randombytes "^2.0.5" +goober@^2.1.16: + version "2.1.19" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.19.tgz#a4b4dcdbba9325b8c4d7380099f9f281f27c6a6f" + integrity sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg== + graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -happy-dom@^20.11.6: - version "20.11.6" - resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-20.11.6.tgz#82e505cc1acf3e8bba99bf0f047761c756477682" - integrity sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg== +happy-dom@^20.14.5: + version "20.14.5" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-20.14.5.tgz#d76cfbb926ebde0575deb3bf92cb3e6bb88b8176" + integrity sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ== dependencies: "@types/node" ">=20.0.0" "@types/whatwg-mimetype" "^3.0.2" @@ -2197,17 +2362,17 @@ picocolors@1.1.1, picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" - integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== +picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== postcss-simple-vars@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz#836b3097a54dcd13dbd3c36a5dbdd512fad2954c" integrity sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A== -postcss@^8.5.26, postcss@^8.5.28: +postcss@^8.5.28: version "8.5.28" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== @@ -2532,6 +2697,11 @@ remark-rehype@^11.0.0: unified "^11.0.0" vfile "^6.0.0" +remove-accents@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.5.0.tgz#77991f37ba212afba162e375b627631315bed687" + integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -2552,29 +2722,29 @@ retry@^0.12.0: resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== -rolldown@~1.2.4: - version "1.2.5" - resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.5.tgz#1f504a7d05260a769e617d950410bbb051498c50" - integrity sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA== +rolldown@~1.2.6: + version "1.2.9" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.9.tgz#b449d73b7aeaf0097adfc25967a76fa1bf304c56" + integrity sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg== dependencies: - "@oxc-project/types" "=0.146.0" + "@oxc-project/types" "=0.150.0" "@rolldown/pluginutils" "^1.0.0" optionalDependencies: - "@rolldown/binding-android-arm-eabi" "1.2.5" - "@rolldown/binding-android-arm64" "1.2.5" - "@rolldown/binding-darwin-arm64" "1.2.5" - "@rolldown/binding-darwin-x64" "1.2.5" - "@rolldown/binding-freebsd-x64" "1.2.5" - "@rolldown/binding-linux-arm-gnueabihf" "1.2.5" - "@rolldown/binding-linux-arm64-gnu" "1.2.5" - "@rolldown/binding-linux-arm64-musl" "1.2.5" - "@rolldown/binding-linux-ppc64-gnu" "1.2.5" - "@rolldown/binding-linux-s390x-gnu" "1.2.5" - "@rolldown/binding-linux-x64-gnu" "1.2.5" - "@rolldown/binding-linux-x64-musl" "1.2.5" - "@rolldown/binding-openharmony-arm64" "1.2.5" - "@rolldown/binding-win32-arm64-msvc" "1.2.5" - "@rolldown/binding-win32-x64-msvc" "1.2.5" + "@rolldown/binding-android-arm-eabi" "1.2.9" + "@rolldown/binding-android-arm64" "1.2.9" + "@rolldown/binding-darwin-arm64" "1.2.9" + "@rolldown/binding-darwin-x64" "1.2.9" + "@rolldown/binding-freebsd-x64" "1.2.9" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.9" + "@rolldown/binding-linux-arm64-gnu" "1.2.9" + "@rolldown/binding-linux-arm64-musl" "1.2.9" + "@rolldown/binding-linux-ppc64-gnu" "1.2.9" + "@rolldown/binding-linux-s390x-gnu" "1.2.9" + "@rolldown/binding-linux-x64-gnu" "1.2.9" + "@rolldown/binding-linux-x64-musl" "1.2.9" + "@rolldown/binding-openharmony-arm64" "1.2.9" + "@rolldown/binding-win32-arm64-msvc" "1.2.9" + "@rolldown/binding-win32-x64-msvc" "1.2.9" rooks@^9.9.0: version "9.9.0" @@ -2593,10 +2763,10 @@ safe-buffer@^5.1.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -sass@^1.103.1: - version "1.103.1" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.103.1.tgz#13b70f5ff69288db956dc27d27c8fb79f3a27c61" - integrity sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw== +sass@^1.104.1: + version "1.104.1" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.104.1.tgz#29a4bdb33c48a8bb656e300a33afcafbfcbc8491" + integrity sha512-yDA+1aIG3EHgN4V/BvuhCvu61FF6hEd4e+9DxikUm9U0CAGuvdIZ/UYy7qbOxjhbbWQraoyLlMuzdRGOSV5Bmw== dependencies: chokidar "^5.0.0" immutable "^5.1.5" @@ -2609,6 +2779,16 @@ scheduler@^0.28.0: resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.28.0.tgz#03d3ca348aeaf8b7502f64468f217b27523f9695" integrity sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw== +seroval-plugins@~1.5.4: + version "1.5.6" + resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.5.6.tgz#7bca495aceb5b2428b9cb44b4b09e67c2de1c948" + integrity sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ== + +seroval@~1.5.4: + version "1.5.6" + resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.5.6.tgz#68d4f6a05c3bde25daaaea6b7220146ff42353ca" + integrity sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA== + set-cookie-parser@^2.6.0: version "2.7.2" resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68" @@ -2624,6 +2804,37 @@ signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +solid-js@^1.9.7: + version "1.9.15" + resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-1.9.15.tgz#209cbd4e0c7108d1e9bd1537ea7f91e03277a763" + integrity sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg== + dependencies: + csstype "^3.1.0" + seroval "~1.5.4" + seroval-plugins "~1.5.4" + +solid-presence@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/solid-presence/-/solid-presence-0.2.0.tgz#1f92958d549fba5ebc5905b33d02ba854132c68d" + integrity sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA== + dependencies: + "@corvu/utils" "~0.4.2" + +solid-prevent-scroll@^0.1.11: + version "0.1.11" + resolved "https://registry.yarnpkg.com/solid-prevent-scroll/-/solid-prevent-scroll-0.1.11.tgz#4d92aeff067ec3c10e3461b850e09b5c9f9ab4a6" + integrity sha512-2PComVCDHaQN/5t7ogEqUYeHasritZKXZ8Sb/VLkl0Web8o9YhEwOo8LSujJEahvMlYCNyKt0zRJeHldOAeWZg== + dependencies: + "@corvu/utils" "~0.4.2" + +solid-transition-group@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/solid-transition-group/-/solid-transition-group-0.2.3.tgz#ef441d9e4620cddc4d29cdbc958b09f94db515c4" + integrity sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg== + dependencies: + "@solid-primitives/refs" "^1.0.5" + "@solid-primitives/transition-group" "^1.0.2" + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -2688,6 +2899,13 @@ stylis@4.2.0: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== +superjson@^2.2.2: + version "2.2.6" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.6.tgz#a223a3a988172a5f9656e2063fe5f733af40d099" + integrity sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA== + dependencies: + copy-anything "^4" + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -2787,10 +3005,10 @@ uncontrollable@^8.0.4: resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-8.0.4.tgz#a0a8307f638795162fafd0550f4a1efa0f8c5eb6" integrity sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ== -undici-types@~8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" - integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== +undici-types@~8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" + integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== unicorn-magic@^0.3.0: version "0.3.0" @@ -2904,15 +3122,15 @@ vite-plugin-checker@^0.14.5: proper-lockfile "^4.1.2" tiny-invariant "^1.3.3" -"vite@^6.0.0 || ^7.0.0 || ^8.0.0", vite@^8.2.2: - version "8.2.2" - resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.2.tgz#399aefad3656145145be110d137a07ea5bb55014" - integrity sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q== +"vite@^6.0.0 || ^7.0.0 || ^8.0.0", vite@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.3.0.tgz#f9565cfd4879d58d28fa64d3c7aff3b372d2ccc8" + integrity sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ== dependencies: lightningcss "^1.33.0" - picomatch "^4.0.5" - postcss "^8.5.26" - rolldown "~1.2.4" + picomatch "^4.0.7" + postcss "^8.5.28" + rolldown "~1.2.6" tinyglobby "^0.2.17" optionalDependencies: fsevents "~2.3.3" From 5f35e4916fbb3461bd124f3acbe67c382e7a1c22 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Wed, 23 Sep 2026 14:10:29 +1000 Subject: [PATCH 18/55] Bump version --- .version | 2 +- README.md | 2 +- SECURITY.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.version b/.version index 3b1fc7950f..7524906967 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.15.1 +2.16.0 diff --git a/README.md b/README.md index 5cd9be1c96..0ee51c3508 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@



- +
diff --git a/SECURITY.md b/SECURITY.md index 66e4bb1f61..a1d30620c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,8 +7,8 @@ Older versions are not actively maintained. | Version | Supported | | ------- | --------- | -| 2.15.x (latest) | :white_check_mark: | -| < 2.15.0 | :x: | +| 2.16.x (latest) | :white_check_mark: | +| < 2.16.0 | :x: | Docker images: `jc21/nginx-proxy-manager:latest`, `jc21/nginx-proxy-manager:2` From 9445038b36eb611718c5edffcc911e5373225524 Mon Sep 17 00:00:00 2001 From: carlosalbertorg Date: Wed, 23 Sep 2026 07:00:19 -0300 Subject: [PATCH 19/55] fix: Use forwarding_host instead of the renamed forward_ip column The stream table's forward_ip column was renamed to forwarding_host back in migration 20210423103500_stream_domain.js, so the stream log source labels were always showing "undefined" for the forwarding target. Spotted by @jc21 in review. --- backend/internal/log-viewer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/log-viewer.js b/backend/internal/log-viewer.js index abbaac5107..2b1b8e9675 100644 --- a/backend/internal/log-viewer.js +++ b/backend/internal/log-viewer.js @@ -165,7 +165,7 @@ const internalLogViewer = { dead: toHostOptions(deadHosts), stream: streams.map((row) => ({ id: row.id, - label: `Port ${row.incoming_port} → ${row.forward_ip}:${row.forwarding_port}`, + label: `Port ${row.incoming_port} → ${row.forwarding_host}:${row.forwarding_port}`, })), }, }; From 900bb1e480b5e7856a4d5ca8c858e0084493f55f Mon Sep 17 00:00:00 2001 From: "Dimas R. Wisnu" Date: Wed, 23 Sep 2026 19:18:51 +0700 Subject: [PATCH 20/55] fix: resolve merge conflicts breaking frontend CI - LocationsFields: remove duplicated conflict block, integrate AccessField into the Row-based layout (fixes JSX parse error) - ProxyHosts/Table: migrate to updated @tanstack/react-table API (useTable, createColumnHelper) - Add missing Estonian translations (action.logs, column.error) - Re-sort en.json locale keys --- .../src/components/Form/LocationsFields.tsx | 66 +++++-------------- frontend/src/locale/src/en.json | 6 +- frontend/src/locale/src/et.json | 6 ++ frontend/src/pages/Nginx/ProxyHosts/Table.tsx | 53 ++++++++------- 4 files changed, 52 insertions(+), 79 deletions(-) diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 7138bdd7a4..9c4d8b3a8a 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -81,14 +81,14 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { setFormField(newRows); }; - const handleAccessListChange = (idx: number, accessListId: number) => { - const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, accessListId } : v)); - setValues(newValues); - setFormField(newValues); + const handleAccessListChange = (id: number, accessListId: number) => { + const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, accessListId } } : r)); + setRows(newRows); + setFormField(newRows); }; - const setFormField = (newValues: ProxyLocation[]) => { - const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== ""); + const setFormField = (newRows: Row[]) => { + const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== ""); setFieldValue(name, filtered); }; @@ -307,6 +307,12 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {

+ handleAccessListChange(row.id, value)} + /> {advVisible.includes(row.id) && (
)}
- handleAccessListChange(idx, value)} - /> - {advVisible.includes(idx) && ( -
- handleChange(idx, "advancedConfig", e.target.value)} - style={{ - fontFamily: - "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace", - borderRadius: "0.3rem", - minHeight: "170px", - }} - /> -
- )} - - - - ))} -
- -
+ ); + }) + )} ); } diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index aa0b47f5e0..da8765a5e8 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -254,9 +254,6 @@ "column.access": { "defaultMessage": "Access" }, - "column.error": { - "defaultMessage": "Error" - }, "column.authorization": { "defaultMessage": "Authorization" }, @@ -275,6 +272,9 @@ "column.email": { "defaultMessage": "Email" }, + "column.error": { + "defaultMessage": "Error" + }, "column.event": { "defaultMessage": "Event" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index 9fd0f74d17..989a153aa1 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -125,6 +125,9 @@ "action.enable": { "defaultMessage": "Lülita sisse" }, + "action.logs": { + "defaultMessage": "Logid" + }, "action.permissions": { "defaultMessage": "Õigused" }, @@ -269,6 +272,9 @@ "column.email": { "defaultMessage": "E-post" }, + "column.error": { + "defaultMessage": "Viga" + }, "column.event": { "defaultMessage": "Sündmus" }, diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index cc4fc603e3..7a9b4cd6b2 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -1,11 +1,5 @@ import { IconDotsVertical, IconEdit, IconFileText, IconPower, IconTrash } from "@tabler/icons-react"; -import { - createColumnHelper, - getCoreRowModel, - getSortedRowModel, - type SortingState, - useReactTable, -} from "@tanstack/react-table"; +import { createColumnHelper, type SortingState, useTable } from "@tanstack/react-table"; import { useMemo, useState } from "react"; import type { ProxyHost } from "src/api/backend"; import { @@ -32,8 +26,17 @@ interface Props { onLogs?: (id: number) => void; onNew?: () => void; } -export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onLogs, onNew, isFiltered }: Props) { - const columnHelper = createColumnHelper(); +export default function Table({ + data, + isFetching, + onEdit, + onDelete, + onDisableToggle, + onLogs, + onNew, + isFiltered, +}: Props) { + const columnHelper = createColumnHelper(); const columns = useMemo( () => [ columnHelper.accessor((row: any) => row.owner, { @@ -117,30 +120,30 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog data={{ id: info.row.original.id }} /> - { - e.preventDefault(); - onEdit?.(info.row.original.id); - }} - > - - - - { e.preventDefault(); - onLogs?.(info.row.original.id); + onEdit?.(info.row.original.id); }} > - - + + - + { + e.preventDefault(); + onLogs?.(info.row.original.id); + }} + > + + + + { From 4389f5df2efea5cda83cad83a45a846f1b38a726 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:40:00 +0000 Subject: [PATCH 21/55] Update caddy:2.11.4 Docker digest to 0c99453 --- caddy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caddy/Dockerfile b/caddy/Dockerfile index ad856ea84a..ee791d828a 100644 --- a/caddy/Dockerfile +++ b/caddy/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.27.0@sha256:bde3983e9c939224420ddaf6b784cc30e09b035a4dea01f581230c50809f372e FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 RUN apk add --no-cache tzdata -COPY --from=caddy:2.11.4@sha256:14a9c00d4e833ebc2b65d36515b37bde3b73f0b323a2663aaafc88953d8c4e3f /usr/bin/caddy /usr/bin/caddy +COPY --from=caddy:2.11.4@sha256:0c994536bddb66445885237f1a5dcc1916bccea922661c76b4e9fc24061f9b52 /usr/bin/caddy /usr/bin/caddy COPY Caddyfile /etc/caddy/Caddyfile CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] From 5431b39ba941befcabb0ca673dc92dd00fdf58d5 Mon Sep 17 00:00:00 2001 From: Zoey2936 <75573284+Zoey2936@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:39:04 +0000 Subject: [PATCH 22/55] update aws-lc version to v5.10.0 Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6d3c8be38f..7422571a08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ SHELL ["/bin/ash", "-eo", "pipefail", "-c"] ARG LUAJIT_INC=/usr/include/luajit-2.1 ARG LUAJIT_LIB=/usr/lib -ARG AWSLC_VER=39b142ec346e514af5e49cbd6036e2ed3b893466 # v5.9.0 +ARG AWSLC_VER=3fe7e081e62131b6776f0d923312b5e6756907ce # v5.10.0 ARG NGINX_VER=45a318d05a0fd23f57ffe9579f7f0969c0fe402a # release-1.31.6 ARG DTR_VER=1.29.2 From 0d9cc1d63aa26006da43d39b3db8d6d726b3a76a Mon Sep 17 00:00:00 2001 From: Zoey Date: Tue, 22 Sep 2026 21:56:51 +0200 Subject: [PATCH 23/55] further simplify locale code Signed-off-by: Zoey --- frontend/src/App.jsx | 40 +++++++++--------- frontend/src/components/Form/AccessFields.jsx | 4 +- .../components/Form/SSLCertificateField.jsx | 6 +-- frontend/src/components/LocalePicker.jsx | 7 ++-- .../Table/Formatter/DateFormatter.jsx | 4 +- .../Table/Formatter/DomainsFormatter.jsx | 4 +- .../Table/Formatter/EventFormatter.jsx | 4 +- .../Formatter/ValueWithDateFormatter.jsx | 4 +- frontend/src/context/LocaleContext.jsx | 22 ---------- frontend/src/context/index.js | 1 - frontend/src/locale/HelpDoc.js | 8 ++-- frontend/src/locale/IntlProvider.jsx | 41 +++++++------------ frontend/src/locale/Utils.js | 5 ++- frontend/src/modals/HelpModal.jsx | 4 +- .../src/pages/Certificates/TableWrapper.jsx | 5 +-- frontend/translations/ui/en.json | 1 + frontend/translations/ui/nl.json | 1 + 17 files changed, 57 insertions(+), 104 deletions(-) delete mode 100644 frontend/src/context/LocaleContext.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2289d8dd5d..f4bfed9170 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,7 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { RawIntlProvider } from "react-intl"; import { ToastContainer } from "react-toastify"; import { queryClient } from "src/api/backend/base"; -import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context"; +import { AuthProvider, ThemeProvider } from "src/context"; import { intl } from "src/locale"; import EasyModal from "src/modules/easyModal"; import Router from "src/Router.jsx"; @@ -10,26 +10,24 @@ import Router from "src/Router.jsx"; function App() { return ( - - - - - - - - - - - - + + + + + + + + + + ); } diff --git a/frontend/src/components/Form/AccessFields.jsx b/frontend/src/components/Form/AccessFields.jsx index 0d37537355..8ac21b08d7 100644 --- a/frontend/src/components/Form/AccessFields.jsx +++ b/frontend/src/components/Form/AccessFields.jsx @@ -2,7 +2,6 @@ import { IconArrowDown, IconArrowUp, IconLock, IconLockOpen2, IconWorld, IconX } import { useFormikContext } from "formik"; import { useState } from "react"; import Select, { components } from "react-select"; -import { useLocaleState } from "src/context"; import { useAccessLists } from "src/hooks"; import { formatDateTime, intl, T } from "src/locale"; @@ -29,7 +28,6 @@ const TypeOption = (props) => ( export function AccessFields({ initialAccessListType, location, initialAccessListIds, name, typeFieldName, onChange }) { const [values, setValues] = useState(initialAccessListIds || []); const [aclValue, setAclValue] = useState(initialAccessListType); - const { locale } = useLocaleState(); const { setFieldValue } = useFormikContext(); const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); @@ -41,7 +39,7 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis { users: item?.items?.length, rules: item?.clients?.length, - date: item?.createdOn ? formatDateTime(item?.createdOn, locale) : "N/A", + date: item?.createdOn ? formatDateTime(item?.createdOn) : "N/A", }, ), meta: item, diff --git a/frontend/src/components/Form/SSLCertificateField.jsx b/frontend/src/components/Form/SSLCertificateField.jsx index a59e8a7d0c..c67a117233 100644 --- a/frontend/src/components/Form/SSLCertificateField.jsx +++ b/frontend/src/components/Form/SSLCertificateField.jsx @@ -1,7 +1,6 @@ import { IconShield } from "@tabler/icons-react"; import { Field, useFormikContext } from "formik"; import Select, { components } from "react-select"; -import { useLocaleState } from "src/context"; import { useCertificates } from "src/hooks"; import { formatDateTime, intl, T } from "src/locale"; @@ -26,7 +25,6 @@ export function SSLCertificateField({ mtlsName = "meta.npmplusMtlsCertificateId", mtlsLabel = "mtls-certificate", }) { - const { locale } = useLocaleState(); const { isLoading, isError, error, data } = useCertificates(); const { values, setFieldValue } = useFormikContext(); const v = values || {}; @@ -71,7 +69,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, locale) : "N/A" })}`, + subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`, icon: , })) || []; @@ -81,7 +79,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, locale) : "N/A" })}`, + subLabel: `${cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`, icon: , })) || []; diff --git a/frontend/src/components/LocalePicker.jsx b/frontend/src/components/LocalePicker.jsx index 014058613e..5fec76947a 100644 --- a/frontend/src/components/LocalePicker.jsx +++ b/frontend/src/components/LocalePicker.jsx @@ -1,12 +1,11 @@ import cn from "clsx"; import { Flag } from "src/components"; -import { useLocaleState } from "src/context"; import { useTheme } from "src/hooks"; -import { changeLocale, getFlagCodeForLocale, localeList, localeOptions } from "src/locale"; +import { changeLocale, getFlagCodeForLocale, localeOptions } from "src/locale"; +import localeList from "translations/lang-list.json" with { type: "json" }; import styles from "./LocalePicker.module.css"; function LocalePicker({ menuAlign = "start" }) { - const { locale } = useLocaleState(); const { getTheme } = useTheme(); const classes = ["btn", "dropdown-toggle", "btn-sm", styles.btn]; @@ -15,7 +14,7 @@ function LocalePicker({ menuAlign = "start" }) { return (
= 0, }); - return {formatDateTime(value, locale)}; + return {formatDateTime(value)}; } diff --git a/frontend/src/components/Table/Formatter/DomainsFormatter.jsx b/frontend/src/components/Table/Formatter/DomainsFormatter.jsx index 39b5cdc141..9e79fb65ad 100644 --- a/frontend/src/components/Table/Formatter/DomainsFormatter.jsx +++ b/frontend/src/components/Table/Formatter/DomainsFormatter.jsx @@ -1,5 +1,4 @@ import cn from "clsx"; -import { useLocaleState } from "src/context"; import { formatDateTime, T } from "src/locale"; const DomainLink = ({ domain, color }) => { @@ -25,7 +24,6 @@ const DomainLink = ({ domain, color }) => { }; export function DomainsFormatter({ domains, createdOn, niceName, provider, color }) { - const { locale } = useLocaleState(); const elms = []; if ((!domains || domains.length === 0) && !niceName) { @@ -52,7 +50,7 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color
{...elms}
{createdOn ? (
- +
) : null}
diff --git a/frontend/src/components/Table/Formatter/EventFormatter.jsx b/frontend/src/components/Table/Formatter/EventFormatter.jsx index aad5d0ba53..ff0b29110d 100644 --- a/frontend/src/components/Table/Formatter/EventFormatter.jsx +++ b/frontend/src/components/Table/Formatter/EventFormatter.jsx @@ -1,6 +1,5 @@ import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react"; import cn from "clsx"; -import { useLocaleState } from "src/context"; import { formatDateTime, T } from "src/locale"; const getEventValue = (event) => { @@ -65,7 +64,6 @@ const getIcon = (row) => { }; export function EventFormatter({ row }) { - const { locale } = useLocaleState(); return (
@@ -73,7 +71,7 @@ export function EventFormatter({ row }) {   — {getEventValue(row)}
-
{formatDateTime(row.createdOn, locale)}
+
{formatDateTime(row.createdOn)}
); } diff --git a/frontend/src/components/Table/Formatter/ValueWithDateFormatter.jsx b/frontend/src/components/Table/Formatter/ValueWithDateFormatter.jsx index 223101cdde..bf31c6d646 100644 --- a/frontend/src/components/Table/Formatter/ValueWithDateFormatter.jsx +++ b/frontend/src/components/Table/Formatter/ValueWithDateFormatter.jsx @@ -1,8 +1,6 @@ -import { useLocaleState } from "src/context"; import { formatDateTime, T } from "src/locale"; export function ValueWithDateFormatter({ value, createdOn, disabled }) { - const { locale } = useLocaleState(); return (
@@ -10,7 +8,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }) {
{createdOn ? (
- +
) : null}
diff --git a/frontend/src/context/LocaleContext.jsx b/frontend/src/context/LocaleContext.jsx deleted file mode 100644 index 3d36347e63..0000000000 --- a/frontend/src/context/LocaleContext.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createContext, useContext, useState } from "react"; -import { getLocale } from "src/locale"; - -const LocaleContext = createContext(null); - -function LocaleProvider({ children }) { - const [locale] = useState(getLocale()); - - const value = { locale }; - - return {children}; -} - -function useLocaleState() { - const context = useContext(LocaleContext); - if (!context) { - throw new Error("useLocaleState must be used within a LocaleProvider"); - } - return context; -} - -export { LocaleProvider, useLocaleState }; diff --git a/frontend/src/context/index.js b/frontend/src/context/index.js index a24e065744..80897e87e6 100644 --- a/frontend/src/context/index.js +++ b/frontend/src/context/index.js @@ -1,3 +1,2 @@ export * from "./AuthContext"; -export * from "./LocaleContext"; export * from "./ThemeContext"; diff --git a/frontend/src/locale/HelpDoc.js b/frontend/src/locale/HelpDoc.js index 35c6d7dee2..622f462451 100644 --- a/frontend/src/locale/HelpDoc.js +++ b/frontend/src/locale/HelpDoc.js @@ -1,13 +1,15 @@ +import { currentLocale } from "./IntlProvider.jsx"; + const helpDocs = import.meta.glob("../../translations/help/*/*.md", { eager: true, import: "default", query: "?raw", }); -export const getHelpFile = (lang, section) => { +export const getHelpFile = (section) => { const doc = - helpDocs[`../../translations/help/${lang}/${section}.md`] ?? + helpDocs[`../../translations/help/${currentLocale}/${section}.md`] ?? helpDocs[`../../translations/help/en/${section}.md`]; - if (!doc) throw new Error(`Cannot load help doc for ${lang}-${section}`); + if (!doc) throw new Error(`Cannot load help doc for ${currentLocale}-${section}`); return doc; }; diff --git a/frontend/src/locale/IntlProvider.jsx b/frontend/src/locale/IntlProvider.jsx index 5a08ffeac2..30a46d52a4 100644 --- a/frontend/src/locale/IntlProvider.jsx +++ b/frontend/src/locale/IntlProvider.jsx @@ -1,45 +1,34 @@ -import { createIntl, createIntlCache } from "react-intl"; -import langList from "translations/lang-list.json" with { type: "json" }; +import { createIntl } from "react-intl"; +import localeList from "translations/lang-list.json" with { type: "json" }; const uiFiles = import.meta.glob("../../translations/ui/*.json", { eager: true, import: "default", }); -const messagesFor = (lang) => uiFiles[`../../translations/ui/${lang}.json`]; - -const localeList = langList; - const localeOptions = ["en", ...Object.keys(localeList).filter((locale) => locale !== "en")]; -const getFlagCodeForLocale = (locale = "en") => localeList[locale]?.flag ?? "EN"; +const storedLocale = window.localStorage.getItem("locale"); +const currentLocale = localeOptions.includes(storedLocale) ? storedLocale : "en"; -const loadMessages = (locale = "en") => ({ - ...messagesFor("en"), - ...messagesFor(locale), +document.documentElement.lang = currentLocale; +if (localeList[currentLocale].rtl) document.dir = "rtl"; + +const intl = createIntl({ + locale: currentLocale, + messages: { + ...uiFiles["../../translations/ui/en.json"], + ...uiFiles[`../../translations/ui/${currentLocale}.json`], + }, }); -const getLocale = () => { - let loc = window.localStorage.getItem("locale"); - if (!loc) loc = document.documentElement.lang; - // finally, fallback - if (!localeOptions.includes(loc)) loc = "en"; - return loc; -}; +const getFlagCodeForLocale = (locale = currentLocale) => localeList[locale].flag; const changeLocale = (lang) => { window.localStorage.setItem("locale", lang); location.reload(); }; -const cache = createIntlCache(); - -const currentLocale = getLocale(); -const initialMessages = loadMessages(currentLocale); -document.documentElement.lang = currentLocale; -if (localeList[currentLocale]?.rtl) document.dir = "rtl"; -const intl = createIntl({ locale: currentLocale, messages: initialMessages }, cache); - // This is a translation component that wraps the translation in a span with a data // attribute so devs can inspect the element to see the translation ID const T = ({ id, data, tData }) => { @@ -63,4 +52,4 @@ const T = ({ id, data, tData }) => { ); }; -export { changeLocale, currentLocale, getFlagCodeForLocale, intl, localeList, localeOptions, T }; +export { changeLocale, currentLocale, getFlagCodeForLocale, intl, localeOptions, T }; diff --git a/frontend/src/locale/Utils.js b/frontend/src/locale/Utils.js index 420bce27e0..a80b4755e7 100644 --- a/frontend/src/locale/Utils.js +++ b/frontend/src/locale/Utils.js @@ -1,4 +1,5 @@ import { fromUnixTime, intlFormat, parseISO } from "date-fns"; +import { currentLocale } from "./IntlProvider.jsx"; const isUnixTimestamp = (value) => { if (typeof value !== "number" && typeof value !== "string") return false; @@ -17,7 +18,7 @@ const parseDate = (value) => { return Number.isNaN(date.getTime()) ? null : date; }; -const formatDateTime = (value, locale = "en-US") => { +const formatDateTime = (value) => { const d = parseDate(value); if (!d) return `${value}`; return intlFormat( @@ -27,7 +28,7 @@ const formatDateTime = (value, locale = "en-US") => { timeStyle: "medium", hourCycle: "h23", }, - { locale }, + { locale: currentLocale }, ); }; diff --git a/frontend/src/modals/HelpModal.jsx b/frontend/src/modals/HelpModal.jsx index e2f34c218a..4c707f4346 100644 --- a/frontend/src/modals/HelpModal.jsx +++ b/frontend/src/modals/HelpModal.jsx @@ -1,6 +1,6 @@ import Markdown from "markdown-to-jsx"; import Modal from "react-bootstrap/Modal"; -import { getHelpFile, getLocale, T } from "src/locale"; +import { getHelpFile, T } from "src/locale"; import EasyModal from "src/modules/easyModal"; const showHelpModal = (section) => { @@ -8,7 +8,7 @@ const showHelpModal = (section) => { }; const HelpModal = EasyModal.create(({ section, visible, remove }) => { - const markdownText = getHelpFile(getLocale(), section); + const markdownText = getHelpFile(section); return ( diff --git a/frontend/src/pages/Certificates/TableWrapper.jsx b/frontend/src/pages/Certificates/TableWrapper.jsx index de5410132e..04d9e98ccf 100644 --- a/frontend/src/pages/Certificates/TableWrapper.jsx +++ b/frontend/src/pages/Certificates/TableWrapper.jsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import Alert from "react-bootstrap/Alert"; import { deleteCertificate, downloadCertificate } from "src/api/backend"; import { Button, certificateProviderTranslation, HasPermission, LoadingPage } from "src/components"; -import { useLocaleState } from "src/context"; import { useCertificates } from "src/hooks"; import { formatDateTime, T } from "src/locale"; import { @@ -20,8 +19,6 @@ import { showError, showObjectSuccess } from "src/notifications"; import Table from "./Table"; export default function TableWrapper() { - const { locale } = useLocaleState(); - const [search, setSearch] = useState(""); const { isFetching, isLoading, isError, error, data } = useCertificates([ "owner", @@ -186,7 +183,7 @@ export default function TableWrapper() {
diff --git a/frontend/translations/ui/en.json b/frontend/translations/ui/en.json index 3a503a1c2e..b3af02f926 100644 --- a/frontend/translations/ui/en.json +++ b/frontend/translations/ui/en.json @@ -192,6 +192,7 @@ "notification.object-enabled": "{object} has been enabled", "notification.object-renewed": "{object} has been renewed", "notification.object-saved": "{object} has been saved", + "notification.object-updated": "{object} has been updated", "notification.success": "Success", "object.actions-title": "{object} #{id}", "object.add": "Add {object}", diff --git a/frontend/translations/ui/nl.json b/frontend/translations/ui/nl.json index 97a5c13f17..f3a28aa463 100644 --- a/frontend/translations/ui/nl.json +++ b/frontend/translations/ui/nl.json @@ -192,6 +192,7 @@ "notification.object-enabled": "{object} is ingeschakeld", "notification.object-renewed": "{object} is vernieuwd", "notification.object-saved": "{object} is opgeslagen", + "notification.object-updated": "{object} is bijgewerkt", "notification.success": "Succes", "object.actions-title": "{object} #{id}", "object.add": "{object} toevoegen", From 10b5a0b47cedca7b1f8b163543b637909162a522 Mon Sep 17 00:00:00 2001 From: Zoey Date: Tue, 22 Sep 2026 23:06:13 +0200 Subject: [PATCH 24/55] make more things simpler Signed-off-by: Zoey --- backend/internal/certificate.js | 18 +++-------- backend/internal/proxy-host.js | 10 ++---- backend/lib/error.js | 16 ---------- frontend/src/api/backend/base.js | 24 +++----------- frontend/src/api/backend/caseConvert.js | 9 +++--- frontend/src/components/Button.jsx | 6 +--- frontend/src/components/Form/AccessFields.jsx | 4 +-- frontend/src/components/LocalePicker.jsx | 6 ++-- frontend/src/components/SiteHeader.jsx | 7 ++-- frontend/src/components/ThemeSwitcher.jsx | 32 ++++++++----------- frontend/src/context/ThemeContext.jsx | 31 +++++------------- frontend/src/hooks/index.js | 1 - frontend/src/hooks/useTheme.js | 6 ---- frontend/src/locale/Utils.js | 14 +------- frontend/src/modals/PermissionsModal.jsx | 5 ++- frontend/src/modals/UserModal.jsx | 3 +- frontend/src/modules/Permissions.js | 2 +- frontend/src/pages/Access/TableWrapper.jsx | 10 +++--- .../src/pages/Certificates/TableWrapper.jsx | 10 +++--- .../pages/Nginx/DeadHosts/TableWrapper.jsx | 12 +++---- .../pages/Nginx/ProxyHosts/TableWrapper.jsx | 12 +++---- .../Nginx/RedirectionHosts/TableWrapper.jsx | 12 +++---- .../src/pages/Nginx/Streams/TableWrapper.jsx | 12 +++---- frontend/src/pages/Users/TableWrapper.jsx | 30 +++++++---------- 24 files changed, 92 insertions(+), 200 deletions(-) delete mode 100644 frontend/src/hooks/useTheme.js diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 3ad9ed53ff..3ab21d4777 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -325,17 +325,9 @@ const internalCertificate = { const archive = new ZipArchive({ zlib: { level: 9 } }); return new Promise((resolve, reject) => { - stream.on("close", () => { - resolve(); - }); - - archive.on("warning", (err) => { - reject(err); - }); - - archive.on("error", (err) => { - reject(err); - }); + stream.on("close", resolve); + archive.on("warning", reject); + archive.on("error", reject); archive.pipe(stream); @@ -688,7 +680,7 @@ const internalCertificate = { process.env.ACME_SERVER, "--cert-name", `npm-${certificate.id}`, - ...(domains.length > 0 ? ["--domains", domains.map((domain) => domainToASCII(domain)).join(",")] : []), + ...(domains.length > 0 ? ["--domains", domains.map(domainToASCII).join(",")] : []), ...ips.flatMap((ip) => ["--ip-address", ip]), ...(certificate.meta.reuse_key ? ["--reuse-key"] : ["--no-reuse-key"]), "--authenticator", @@ -726,7 +718,7 @@ const internalCertificate = { "--cert-name", `npm-${certificate.id}`, "--domains", - certificate.domain_names.map((domain_name) => domainToASCII(domain_name)).join(","), + certificate.domain_names.map(domainToASCII).join(","), ...(certificate.meta.reuse_key ? ["--reuse-key"] : ["--no-reuse-key"]), "--authenticator", dnsPlugin.full_plugin_name, diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index c5ded6e082..7280024bcc 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -174,17 +174,13 @@ const internalProxyHost = { if (!row.enabled) { // No need to add nginx config if host is disabled - return internalProxyHostAccessList.maskAccessListItems( - _.omit(internalHost.cleanRowCertificateMeta(row), omissions()), - ); + return internalProxyHostAccessList.maskAccessListItems(internalHost.cleanRowCertificateMeta(row)); } // Configure nginx row.meta = await internalNginx.configure(proxyHostModel, "proxy_host", row); - return internalProxyHostAccessList.maskAccessListItems( - _.omit(internalHost.cleanRowCertificateMeta(row), omissions()), - ); + return internalProxyHostAccessList.maskAccessListItems(internalHost.cleanRowCertificateMeta(row)); }, /** @@ -317,7 +313,7 @@ const internalProxyHost = { action: "enabled", object_type: "proxy-host", object_id: row.id, - meta: _.omit(internalProxyHostAccessList.maskAccessListItems(row), omissions()), + meta: internalProxyHostAccessList.maskAccessListItems(row), }); return true; diff --git a/backend/lib/error.js b/backend/lib/error.js index 85b33bfcc7..d485ac80a2 100644 --- a/backend/lib/error.js +++ b/backend/lib/error.js @@ -28,14 +28,6 @@ const errs = { this.status = 401; }, - InternalError: function (message) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message; - this.status = 500; - this.public = false; - }, - InternalValidationError: function (message) { Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; @@ -52,14 +44,6 @@ const errs = { this.public = true; }, - CacheError: function (message) { - Error.captureStackTrace(this, this.constructor); - this.name = this.constructor.name; - this.message = message; - this.status = 500; - this.public = false; - }, - ValidationError: function (message) { Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; diff --git a/frontend/src/api/backend/base.js b/frontend/src/api/backend/base.js index b336b308c1..e76072cf93 100644 --- a/frontend/src/api/backend/base.js +++ b/frontend/src/api/backend/base.js @@ -1,29 +1,17 @@ import { QueryClient } from "@tanstack/react-query"; import queryString from "query-string"; import AuthStore from "src/modules/AuthStore"; -import { camelizeKeys, decamelize, decamelizeKeys } from "./caseConvert"; +import { camelizeKeys, decamelizeKeys } from "./caseConvert"; export const queryClient = new QueryClient(); const contentTypeHeader = "Content-Type"; -function decamelizeParams(params) { - if (!params) { - return; - } - const result = {}; - for (const [key, value] of Object.entries(params)) { - result[decamelize(key)] = value; - } - - return result; -} - function buildUrl({ url, params }) { const endpoint = url.replace(/^\/|\/$/g, ""); const baseUrl = `/api/${endpoint}`; const apiUrl = queryString.stringifyUrl({ url: baseUrl, - query: decamelizeParams(params), + query: decamelizeKeys(params), }); return apiUrl; } @@ -52,16 +40,12 @@ async function processResponse(response, reload = true) { return camelizeKeys(payload); } -async function baseGet({ url, params }, abortController) { +export async function get({ url, params, reload }, abortController) { const apiUrl = buildUrl({ url, params }); const method = "GET"; const signal = abortController?.signal; const response = await fetch(apiUrl, { method, signal }); - return response; -} - -export async function get(args, abortController) { - return processResponse(await baseGet(args, abortController), args.reload); + return processResponse(response, reload); } export async function download({ url, params }, filename = "download.file") { diff --git a/frontend/src/api/backend/caseConvert.js b/frontend/src/api/backend/caseConvert.js index d301aec50a..4f1028327e 100644 --- a/frontend/src/api/backend/caseConvert.js +++ b/frontend/src/api/backend/caseConvert.js @@ -2,9 +2,6 @@ const separatorPattern = /[-_\s]+(.)?/g; const firstCharPattern = /^./; const upperBoundaryPattern = /(?=[A-Z])/; -const camelize = (s) => - s.replace(separatorPattern, (_, c) => (c ? c.toUpperCase() : "")).replace(firstCharPattern, (c) => c.toLowerCase()); -export const decamelize = (s) => s.split(upperBoundaryPattern).join("_").toLowerCase(); const mapKeys = (fn) => { const walk = (o) => { if (Array.isArray(o)) return o.map(walk); @@ -14,5 +11,7 @@ const mapKeys = (fn) => { }; return walk; }; -export const camelizeKeys = mapKeys(camelize); -export const decamelizeKeys = mapKeys(decamelize); +export const camelizeKeys = mapKeys((s) => + s.replace(separatorPattern, (_, c) => (c ? c.toUpperCase() : "")).replace(firstCharPattern, (c) => c.toLowerCase()), +); +export const decamelizeKeys = mapKeys((s) => s.split(upperBoundaryPattern).join("_").toLowerCase()); diff --git a/frontend/src/components/Button.jsx b/frontend/src/components/Button.jsx index 352900483a..08cbe96203 100644 --- a/frontend/src/components/Button.jsx +++ b/frontend/src/components/Button.jsx @@ -13,10 +13,6 @@ function Button({ isLoading, disabled, }) { - const myOnClick = () => { - if (!isLoading) onClick?.(); - }; - const cns = cn( "btn", className, @@ -29,7 +25,7 @@ function Button({ ); return ( - ); diff --git a/frontend/src/components/Form/AccessFields.jsx b/frontend/src/components/Form/AccessFields.jsx index 8ac21b08d7..f9be3b28a8 100644 --- a/frontend/src/components/Form/AccessFields.jsx +++ b/frontend/src/components/Form/AccessFields.jsx @@ -86,8 +86,6 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis return ret; }; - const findFirstAvailableOption = () => (options.length > 0 ? options[0] : null); - const applyUpdatedValues = (newValues) => { setValues(newValues); void setFieldValue(name, newValues); @@ -100,7 +98,7 @@ export function AccessFields({ initialAccessListType, location, initialAccessLis }; const handleAdd = () => { - const newAccessOption = findFirstAvailableOption(); + const newAccessOption = options[0]; if (newAccessOption?.meta.id) { const newValues = [...values, newAccessOption.meta.id]; applyUpdatedValues(newValues); diff --git a/frontend/src/components/LocalePicker.jsx b/frontend/src/components/LocalePicker.jsx index 5fec76947a..414e8d72f5 100644 --- a/frontend/src/components/LocalePicker.jsx +++ b/frontend/src/components/LocalePicker.jsx @@ -1,15 +1,15 @@ import cn from "clsx"; import { Flag } from "src/components"; -import { useTheme } from "src/hooks"; +import { useThemeState } from "src/context"; import { changeLocale, getFlagCodeForLocale, localeOptions } from "src/locale"; import localeList from "translations/lang-list.json" with { type: "json" }; import styles from "./LocalePicker.module.css"; function LocalePicker({ menuAlign = "start" }) { - const { getTheme } = useTheme(); + const { theme } = useThemeState(); const classes = ["btn", "dropdown-toggle", "btn-sm", styles.btn]; - const cns = cn(...classes, getTheme() === "dark" ? "btn-ghost-dark" : "btn-ghost-light"); + const cns = cn(...classes, theme === "dark" ? "btn-ghost-dark" : "btn-ghost-light"); return (
diff --git a/frontend/src/components/SiteHeader.jsx b/frontend/src/components/SiteHeader.jsx index e95c073fd3..01370036bd 100644 --- a/frontend/src/components/SiteHeader.jsx +++ b/frontend/src/components/SiteHeader.jsx @@ -4,11 +4,12 @@ import { useAuthState } from "src/context"; import { useUser } from "src/hooks"; import { T } from "src/locale"; import { showChangePasswordModal, showMfaModal, showUserModal } from "src/modals"; +import { isAdmin } from "src/modules/Permissions"; import styles from "./SiteHeader.module.css"; export function SiteHeader() { const { data: currentUser } = useUser("me"); - const isAdmin = currentUser?.roles.includes("admin"); + const role = isAdmin(currentUser?.roles) ? "role.admin" : "role.standard-user"; const { logout, logoutEverywhere } = useAuthState(); return ( @@ -66,7 +67,7 @@ export function SiteHeader() {
{currentUser?.name}
- +
@@ -80,7 +81,7 @@ export function SiteHeader() {
{currentUser?.name}
- +
diff --git a/frontend/src/components/ThemeSwitcher.jsx b/frontend/src/components/ThemeSwitcher.jsx index 0e0990ceb7..6bb7ea8896 100644 --- a/frontend/src/components/ThemeSwitcher.jsx +++ b/frontend/src/components/ThemeSwitcher.jsx @@ -1,36 +1,30 @@ import { IconMoon, IconSun } from "@tabler/icons-react"; import cn from "clsx"; -import { Button } from "src/components"; -import { useTheme } from "src/hooks"; +import { useThemeState } from "src/context"; +import { intl } from "src/locale"; import styles from "./ThemeSwitcher.module.css"; function ThemeSwitcher({ className }) { - const { setTheme } = useTheme(); + const { setTheme } = useThemeState(); return (
- - +
); } diff --git a/frontend/src/context/ThemeContext.jsx b/frontend/src/context/ThemeContext.jsx index b4afe610dc..7d8bccbac9 100644 --- a/frontend/src/context/ThemeContext.jsx +++ b/frontend/src/context/ThemeContext.jsx @@ -6,18 +6,13 @@ const Dark = "dark"; const ThemeContext = createContext(undefined); -const getBrowserDefault = () => { - if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - return Dark; - } - return Light; -}; - export const ThemeProvider = ({ children }) => { - const [theme, setThemeState] = useState(() => { + const [theme, setTheme] = useState(() => { // Try to read theme from localStorage or use the browser default - const stored = localStorage.getItem(StorageKey); - return stored || getBrowserDefault(); + return ( + localStorage.getItem(StorageKey) || + (window.matchMedia("(prefers-color-scheme: dark)").matches ? Dark : Light) + ); }); useEffect(() => { @@ -29,24 +24,14 @@ export const ThemeProvider = ({ children }) => { meta.media = meta.dataset.theme === theme ? "all" : "not all"; }, [theme]); - const toggleTheme = () => { - setThemeState((prev) => (prev === Light ? Dark : Light)); - }; - - const setTheme = (newTheme) => { - setThemeState(newTheme); - }; - - const getTheme = () => theme; - document.documentElement.setAttribute("data-bs-theme", theme); - return {children}; + return {children}; }; -export function useTheme() { +export function useThemeState() { const context = useContext(ThemeContext); if (!context) { - throw new Error("useTheme must be used within a ThemeProvider"); + throw new Error("useThemeState must be used within a ThemeProvider"); } return context; } diff --git a/frontend/src/hooks/index.js b/frontend/src/hooks/index.js index 47a89a3709..977bbddf94 100644 --- a/frontend/src/hooks/index.js +++ b/frontend/src/hooks/index.js @@ -18,6 +18,5 @@ export * from "./useRedirectionHosts"; export * from "./useSetting"; export * from "./useStream"; export * from "./useStreams"; -export * from "./useTheme"; export * from "./useUser"; export * from "./useUsers"; diff --git a/frontend/src/hooks/useTheme.js b/frontend/src/hooks/useTheme.js deleted file mode 100644 index 013a71eed5..0000000000 --- a/frontend/src/hooks/useTheme.js +++ /dev/null @@ -1,6 +0,0 @@ -import { useTheme as useThemeContext } from "src/context"; - -// Simple hook wrapper for clarity and scalability -const useTheme = () => useThemeContext(); - -export { useTheme }; diff --git a/frontend/src/locale/Utils.js b/frontend/src/locale/Utils.js index a80b4755e7..4d4db22eb5 100644 --- a/frontend/src/locale/Utils.js +++ b/frontend/src/locale/Utils.js @@ -1,20 +1,8 @@ import { fromUnixTime, intlFormat, parseISO } from "date-fns"; import { currentLocale } from "./IntlProvider.jsx"; -const isUnixTimestamp = (value) => { - if (typeof value !== "number" && typeof value !== "string") return false; - const num = Number(value); - if (!Number.isFinite(num)) return false; - // Check plausible Unix timestamp range: from 1970 to ~year 3000 - // Support both seconds and milliseconds - if (num > 0 && num < 10000000000) return true; // seconds (<= 10 digits) - if (num >= 10000000000 && num < 32503680000000) return true; // milliseconds (<= 13 digits) - return false; -}; - const parseDate = (value) => { - if (typeof value !== "number" && typeof value !== "string") return null; - const date = isUnixTimestamp(value) ? fromUnixTime(Number(value)) : parseISO(`${value}`); + const date = typeof value === "number" ? fromUnixTime(value) : parseISO(`${value}`); return Number.isNaN(date.getTime()) ? null : date; }; diff --git a/frontend/src/modals/PermissionsModal.jsx b/frontend/src/modals/PermissionsModal.jsx index f6c29c1780..a0b8149b07 100644 --- a/frontend/src/modals/PermissionsModal.jsx +++ b/frontend/src/modals/PermissionsModal.jsx @@ -9,6 +9,7 @@ import { Button, Loading } from "src/components"; import { useUser } from "src/hooks"; import { T } from "src/locale"; import EasyModal from "src/modules/easyModal"; +import { isAdmin } from "src/modules/Permissions"; import styles from "./PermissionsModal.module.css"; const showPermissionsModal = (id) => { @@ -131,8 +132,6 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }) => { ); }; - const isAdmin = data?.roles.indexOf("admin") !== -1; - return ( {!isLoading && error && ( @@ -215,7 +214,7 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }) => { )}
- {!isAdmin && ( + {!isAdmin(data?.roles) && ( <>
diff --git a/frontend/src/modals/UserModal.jsx b/frontend/src/modals/UserModal.jsx index 157251cfad..b55c5b1426 100644 --- a/frontend/src/modals/UserModal.jsx +++ b/frontend/src/modals/UserModal.jsx @@ -8,6 +8,7 @@ import { Button, Loading } from "src/components"; import { useSetUser, useUser } from "src/hooks"; import { intl, T } from "src/locale"; import EasyModal from "src/modules/easyModal"; +import { isAdmin } from "src/modules/Permissions"; import { validateEmail, validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; @@ -85,7 +86,7 @@ const UserModal = EasyModal.create(({ id, visible, remove }) => { initialValues={{ name: data?.name, email: data?.email, - isAdmin: data?.roles?.includes("admin"), + isAdmin: isAdmin(data?.roles), isDisabled: data?.isDisabled, }} onSubmit={onSubmit} diff --git a/frontend/src/modules/Permissions.js b/frontend/src/modules/Permissions.js index c70516daeb..ebf0061195 100644 --- a/frontend/src/modules/Permissions.js +++ b/frontend/src/modules/Permissions.js @@ -21,4 +21,4 @@ const hasPermission = (section, perm, userPerms, roles) => { const isAdmin = (roles) => roles?.includes("admin") || false; -export { hasPermission }; +export { hasPermission, isAdmin }; diff --git a/frontend/src/pages/Access/TableWrapper.jsx b/frontend/src/pages/Access/TableWrapper.jsx index 6b257ff4f9..6aa9f08bff 100644 --- a/frontend/src/pages/Access/TableWrapper.jsx +++ b/frontend/src/pages/Access/TableWrapper.jsx @@ -29,11 +29,6 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } - const handleDelete = async (id) => { - await deleteAccessList(id); - showObjectSuccess("access-list", "deleted"); - }; - let filtered = null; if (search && data) { filtered = data?.filter((item) => item.name.toLowerCase().includes(search)); @@ -93,7 +88,10 @@ export default function TableWrapper() { const accessList = data?.find((item) => item.id === id); showDeleteConfirmModal({ title: , - onConfirm: () => handleDelete(id), + onConfirm: async () => { + await deleteAccessList(id); + showObjectSuccess("access-list", "deleted"); + }, invalidations: [["access-lists"], ["access-list", id], ["proxy-hosts"], ["proxy-host"]], children: , subject: accessList?.name, diff --git a/frontend/src/pages/Certificates/TableWrapper.jsx b/frontend/src/pages/Certificates/TableWrapper.jsx index 04d9e98ccf..152c7d4af3 100644 --- a/frontend/src/pages/Certificates/TableWrapper.jsx +++ b/frontend/src/pages/Certificates/TableWrapper.jsx @@ -43,11 +43,6 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } - const handleDelete = async (id) => { - await deleteCertificate(id); - showObjectSuccess("certificate", "deleted"); - }; - const handleDownload = async (id) => { try { await downloadCertificate(id); @@ -165,7 +160,10 @@ export default function TableWrapper() { const domainNames = certificate?.domainNames.join(", "); showDeleteConfirmModal({ title: , - onConfirm: () => handleDelete(id), + onConfirm: async () => { + await deleteCertificate(id); + showObjectSuccess("certificate", "deleted"); + }, invalidations: [["certificates"], ["certificate", id]], children: , subject: domainNames || certificate?.niceName, diff --git a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.jsx b/frontend/src/pages/Nginx/DeadHosts/TableWrapper.jsx index 40fee69c1d..f91e8d6048 100644 --- a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.jsx +++ b/frontend/src/pages/Nginx/DeadHosts/TableWrapper.jsx @@ -32,11 +32,6 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } - const handleDelete = async (id) => { - await deleteDeadHost(id); - showObjectSuccess("dead-host", "deleted"); - }; - const handleDisableToggle = async (id, enabled) => { await toggleDeadHost(id, enabled); await Promise.all([ @@ -57,7 +52,7 @@ export default function TableWrapper() { } const displayedHosts = filtered ?? data ?? []; - const groupingActive = displayedHosts.some((item) => getDirectory(item)); + const groupingActive = displayedHosts.some(getDirectory); const sharedTableProps = { isFiltered: Boolean(search), @@ -69,7 +64,10 @@ export default function TableWrapper() { const host = data?.find((item) => item.id === id); showDeleteConfirmModal({ title: , - onConfirm: () => handleDelete(id), + onConfirm: async () => { + await deleteDeadHost(id); + showObjectSuccess("dead-host", "deleted"); + }, invalidations: [["dead-hosts"], ["dead-host", id]], children: , subject: host?.domainNames.join(", "), diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.jsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.jsx index 01f4657978..64ff78b1d4 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.jsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.jsx @@ -32,11 +32,6 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } - const handleDelete = async (id) => { - await deleteProxyHost(id); - showObjectSuccess("proxy-host", "deleted"); - }; - const handleDisableToggle = async (id, enabled) => { await toggleProxyHost(id, enabled); await Promise.all([ @@ -60,7 +55,7 @@ export default function TableWrapper() { } const displayedHosts = filtered ?? data ?? []; - const groupingActive = displayedHosts.some((item) => getDirectory(item)); + const groupingActive = displayedHosts.some(getDirectory); const sharedTableProps = { isFiltered: Boolean(search), @@ -73,7 +68,10 @@ export default function TableWrapper() { const host = data?.find((item) => item.id === id); showDeleteConfirmModal({ title: , - onConfirm: () => handleDelete(id), + onConfirm: async () => { + await deleteProxyHost(id); + showObjectSuccess("proxy-host", "deleted"); + }, invalidations: [["proxy-hosts"], ["proxy-host", id]], children: , subject: host?.domainNames.join(", "), diff --git a/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.jsx b/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.jsx index 012c274de5..8605c3dbe4 100644 --- a/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.jsx +++ b/frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.jsx @@ -32,11 +32,6 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } - const handleDelete = async (id) => { - await deleteRedirectionHost(id); - showObjectSuccess("redirection-host", "deleted"); - }; - const handleDisableToggle = async (id, enabled) => { await toggleRedirectionHost(id, enabled); await Promise.all([ @@ -59,7 +54,7 @@ export default function TableWrapper() { } const displayedHosts = filtered ?? data ?? []; - const groupingActive = displayedHosts.some((item) => getDirectory(item)); + const groupingActive = displayedHosts.some(getDirectory); const sharedTableProps = { isFiltered: Boolean(search), @@ -71,7 +66,10 @@ export default function TableWrapper() { const host = data?.find((item) => item.id === id); showDeleteConfirmModal({ title: , - onConfirm: () => handleDelete(id), + onConfirm: async () => { + await deleteRedirectionHost(id); + showObjectSuccess("redirection-host", "deleted"); + }, invalidations: [["redirection-hosts"], ["redirection-host", id]], children: , subject: host?.domainNames.join(", "), diff --git a/frontend/src/pages/Nginx/Streams/TableWrapper.jsx b/frontend/src/pages/Nginx/Streams/TableWrapper.jsx index f8e7475f55..98503a5f4c 100644 --- a/frontend/src/pages/Nginx/Streams/TableWrapper.jsx +++ b/frontend/src/pages/Nginx/Streams/TableWrapper.jsx @@ -32,11 +32,6 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } - const handleDelete = async (id) => { - await deleteStream(id); - showObjectSuccess("stream", "deleted"); - }; - const handleDisableToggle = async (id, enabled) => { await toggleStream(id, enabled); await Promise.all([ @@ -61,7 +56,7 @@ export default function TableWrapper() { } const displayedStreams = filtered ?? data ?? []; - const groupingActive = displayedStreams.some((item) => getDirectory(item)); + const groupingActive = displayedStreams.some(getDirectory); const sharedTableProps = { isFiltered: Boolean(search), @@ -73,7 +68,10 @@ export default function TableWrapper() { const stream = data?.find((item) => item.id === id); showDeleteConfirmModal({ title: , - onConfirm: () => handleDelete(id), + onConfirm: async () => { + await deleteStream(id); + showObjectSuccess("stream", "deleted"); + }, invalidations: [["streams"], ["stream", id]], children: , subject: stream ? `${stream.incomingPort} → ${stream.forwardingHost}:${stream.forwardingPort}` : null, diff --git a/frontend/src/pages/Users/TableWrapper.jsx b/frontend/src/pages/Users/TableWrapper.jsx index edb5d26f6a..0925c0cf5d 100644 --- a/frontend/src/pages/Users/TableWrapper.jsx +++ b/frontend/src/pages/Users/TableWrapper.jsx @@ -31,11 +31,6 @@ export default function TableWrapper() { return {error?.message || "Unknown error"}; } - const handleDelete = async (id) => { - await deleteUser(id); - showObjectSuccess("user", "deleted"); - }; - const handleDisableToggle = async (id, enabled) => { await toggleUser(id, enabled); await Promise.all([ @@ -45,16 +40,6 @@ export default function TableWrapper() { showObjectSuccess("user", enabled ? "enabled" : "disabled"); }; - const handleResetMfa = async (id) => { - await adminDisableMfa(id); - showObjectSuccess("user", "updated"); - }; - - const handleRevokeSessions = async (id) => { - await revokeSessions(id); - showObjectSuccess("user", "updated"); - }; - let filtered = null; if (search && data) { filtered = data?.filter( @@ -111,7 +96,10 @@ export default function TableWrapper() { children: , subject: user?.name, details: user?.email, - onConfirm: () => handleResetMfa(id), + onConfirm: async () => { + await adminDisableMfa(id); + showObjectSuccess("user", "updated"); + }, invalidations: [["users"], ["user", id]], }); }} @@ -122,7 +110,10 @@ export default function TableWrapper() { children: , subject: user?.name, details: user?.email, - onConfirm: () => handleRevokeSessions(id), + onConfirm: async () => { + await revokeSessions(id); + showObjectSuccess("user", "updated"); + }, invalidations: [["users"], ["user", id]], }); }} @@ -130,7 +121,10 @@ export default function TableWrapper() { const user = data?.find((item) => item.id === id); showDeleteConfirmModal({ title: , - onConfirm: () => handleDelete(id), + onConfirm: async () => { + await deleteUser(id); + showObjectSuccess("user", "deleted"); + }, invalidations: [["users"], ["user", id]], children: , subject: user?.name, From aa2ae1a3397c03f83e6dd1ccb2a2e454dac65931 Mon Sep 17 00:00:00 2001 From: Zoey Date: Wed, 23 Sep 2026 22:57:31 +0200 Subject: [PATCH 25/55] merge upstream (drop log viewer) Signed-off-by: Zoey --- backend/internal/log-viewer.js | 229 ---------------- backend/lib/access/logs-list.json | 7 - backend/routes/logs.js | 106 -------- .../schema/components/log-sources-object.json | 69 ----- .../schema/components/log-tail-object.json | 30 --- backend/schema/paths/logs/sources/get.json | 36 --- backend/schema/paths/logs/tail/get.json | 100 ------- backend/schema/swagger.json | 14 - frontend/src/Router.test.tsx | 79 ------ frontend/src/api/backend/getLogSources.ts | 8 - frontend/src/api/backend/getLogTail.ts | 19 -- frontend/src/api/backend/index.js | 2 - frontend/src/hooks/index.js | 2 - frontend/src/hooks/useLogSources.ts | 15 -- frontend/src/hooks/useLogTail.ts | 25 -- frontend/src/pages/Logs/LogLines.tsx | 76 ------ frontend/src/pages/Logs/LogViewer.module.css | 15 -- frontend/src/pages/Logs/LogViewer.tsx | 249 ------------------ frontend/src/pages/Logs/index.tsx | 13 - frontend/translations/ui/en.json | 15 ++ frontend/translations/ui/et.json | 15 ++ 21 files changed, 30 insertions(+), 1094 deletions(-) delete mode 100644 backend/internal/log-viewer.js delete mode 100644 backend/lib/access/logs-list.json delete mode 100644 backend/routes/logs.js delete mode 100644 backend/schema/components/log-sources-object.json delete mode 100644 backend/schema/components/log-tail-object.json delete mode 100644 backend/schema/paths/logs/sources/get.json delete mode 100644 backend/schema/paths/logs/tail/get.json delete mode 100644 frontend/src/Router.test.tsx delete mode 100644 frontend/src/api/backend/getLogSources.ts delete mode 100644 frontend/src/api/backend/getLogTail.ts delete mode 100644 frontend/src/hooks/useLogSources.ts delete mode 100644 frontend/src/hooks/useLogTail.ts delete mode 100644 frontend/src/pages/Logs/LogLines.tsx delete mode 100644 frontend/src/pages/Logs/LogViewer.module.css delete mode 100644 frontend/src/pages/Logs/LogViewer.tsx delete mode 100644 frontend/src/pages/Logs/index.tsx diff --git a/backend/internal/log-viewer.js b/backend/internal/log-viewer.js deleted file mode 100644 index 2b1b8e9675..0000000000 --- a/backend/internal/log-viewer.js +++ /dev/null @@ -1,229 +0,0 @@ -import fs from "node:fs"; -import errs from "../lib/error.js"; -import internalDeadHost from "./dead-host.js"; -import internalProxyHost from "./proxy-host.js"; -import internalRedirectionHost from "./redirection-host.js"; -import internalStream from "./stream.js"; - -const SYSTEM_LOG_FILE = "/data/logs/backend.log"; -const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log"; - -// Matches the access_log/error_log paths written by the nginx templates -// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf). -// This is a fixed, server-side lookup table - a host log path is always derived -// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied -// path or filename, so there is no path-traversal surface here. -const HOST_FILE_PREFIX = { - proxy: "proxy-host", - redirection: "redirection-host", - dead: "dead-host", - stream: "stream", -}; - -const DEFAULT_LINES = 200; -const MAX_LINES = 1000; -const CHUNK_SIZE = 64 * 1024; -// Never scan further back than this, regardless of how many lines were requested, -// so a huge or pathological log file can't turn a single request into unbounded I/O. -const MAX_SCAN_BYTES = 5 * 1024 * 1024; - -/** - * Reads at most `maxLines` lines from the end of a file, without loading the - * whole file into memory. Reads backwards in fixed-size chunks until enough - * newlines have been seen, the start of the file is reached, or the hard - * MAX_SCAN_BYTES ceiling is hit. - * - * @param {String} filePath - * @param {Number} maxLines - * @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>} - */ -const readLastLines = async (filePath, maxLines) => { - let handle; - try { - handle = await fs.promises.open(filePath, "r"); - const stat = await handle.stat(); - const { size } = stat; - - if (size === 0) { - return { lines: [], size: 0, truncated: false }; - } - - let position = size; - let scanned = 0; - let newlineCount = 0; - const chunks = []; - - while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) { - const readSize = Math.min(CHUNK_SIZE, position); - position -= readSize; - const buffer = Buffer.alloc(readSize); - await handle.read(buffer, 0, readSize, position); - scanned += readSize; - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === 0x0a) newlineCount++; - } - chunks.unshift(buffer); - } - - const truncated = position > 0 && scanned >= MAX_SCAN_BYTES; - - // If we didn't start reading from byte 0, the first line in our buffer is only - // partial *unless* it happens that `position` landed exactly on a line boundary - // (the byte right before it is a newline) - check that one byte to avoid - // silently dropping a perfectly valid line. - let startsOnLineBoundary = position === 0; - if (position > 0) { - const boundaryByte = Buffer.alloc(1); - await handle.read(boundaryByte, 0, 1, position - 1); - startsOnLineBoundary = boundaryByte[0] === 0x0a; - } - - const text = Buffer.concat(chunks).toString("utf8"); - const allLines = text.split("\n"); - - if (!startsOnLineBoundary && allLines.length > 0) { - allLines.shift(); - } - // Drop the trailing empty element caused by a final trailing newline. - if (allLines.length > 0 && allLines[allLines.length - 1] === "") { - allLines.pop(); - } - - return { lines: allLines.slice(-maxLines), size, truncated }; - } finally { - if (handle) { - await handle.close(); - } - } -}; - -/** - * Resolves a validated {type, host_type, host_id, channel} selection to the - * fixed, absolute path of the log file on disk. Throws if the combination - * isn't a recognised source. - * - * `channel` ("access" | "error") picks which of the two log files nginx writes - * per host - it's deliberately not named "stream" to avoid confusion with the - * "stream" host_type (TCP/UDP stream hosts). - * - * @param {Object} data - * @returns {String} - */ -const resolveFilePath = (data) => { - switch (data.type) { - case "system": - return SYSTEM_LOG_FILE; - case "letsencrypt": - return LETSENCRYPT_LOG_FILE; - case "host": { - const prefix = HOST_FILE_PREFIX[data.host_type]; - if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) { - throw new errs.ValidationError("Invalid host log source"); - } - return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`; - } - default: - throw new errs.ItemNotFoundError(data.type); - } -}; - -/** - * @param {Array} rows - * @returns {Array} - */ -const toHostOptions = (rows) => - rows.map((row) => ({ - id: row.id, - label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`, - })); - -const internalLogViewer = { - /** - * Lists the log sources available for the log viewer: the system (backend) - * log, the Let's Encrypt (certbot) log, and one entry per host the caller - * can see, for each host type. - * - * @param {Access} access - * @returns {Promise} - */ - listSources: async (access) => { - await access.can("logs:list"); - - const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([ - internalProxyHost.getAll(access), - internalRedirectionHost.getAll(access), - internalDeadHost.getAll(access), - internalStream.getAll(access), - ]); - - return { - system: { label: "System" }, - letsencrypt: { label: "Let's Encrypt" }, - hosts: { - proxy: toHostOptions(proxyHosts), - redirection: toHostOptions(redirectionHosts), - dead: toHostOptions(deadHosts), - stream: streams.map((row) => ({ - id: row.id, - label: `Port ${row.incoming_port} → ${row.forwarding_host}:${row.forwarding_port}`, - })), - }, - }; - }, - - /** - * Returns the last N lines of the requested log source, optionally - * filtered by level and/or a plain-text search term. - * - * @param {Access} access - * @param {Object} data - * @param {String} data.type "system" | "letsencrypt" | "host" - * @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream" - * @param {Number} [data.host_id] - * @param {String} [data.channel] "access" | "error" - * @param {Number} [data.lines] - * @param {String} [data.level] - * @param {String} [data.search] - * @returns {Promise} - */ - tail: async (access, data) => { - await access.can("logs:list"); - - const filePath = resolveFilePath(data); - const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES); - - let result; - try { - result = await readLastLines(filePath, lines); - } catch (err) { - if (err.code === "ENOENT") { - return { lines: [], size: 0, truncated: false, exists: false }; - } - throw err; - } - - let outputLines = result.lines; - - // Only the system log is written in our own "LEVEL [scope]" format - level - // filtering on nginx/certbot lines would just match nothing and look like an - // empty log, so the filter is a no-op for any other source. - if (data.type === "system" && data.level) { - const needle = ` ${data.level.toUpperCase().padEnd(7)} `; - outputLines = outputLines.filter((line) => line.includes(needle)); - } - - if (data.search) { - const needle = data.search.toLowerCase(); - outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle)); - } - - return { - lines: outputLines, - size: result.size, - truncated: result.truncated, - exists: true, - }; - }, -}; - -export default internalLogViewer; diff --git a/backend/lib/access/logs-list.json b/backend/lib/access/logs-list.json deleted file mode 100644 index aeadc94ba9..0000000000 --- a/backend/lib/access/logs-list.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "anyOf": [ - { - "$ref": "roles#/definitions/admin" - } - ] -} diff --git a/backend/routes/logs.js b/backend/routes/logs.js deleted file mode 100644 index 9088507b3a..0000000000 --- a/backend/routes/logs.js +++ /dev/null @@ -1,106 +0,0 @@ -import express from "express"; -import internalLogViewer from "../internal/log-viewer.js"; -import jwtdecode from "../lib/express/jwt-decode.js"; -import validator from "../lib/validator/index.js"; -import { debug, express as logger } from "../logger.js"; - -const router = express.Router({ - caseSensitive: true, - strict: true, - mergeParams: true, -}); - -/** - * /api/logs/sources - */ -router - .route("/sources") - .options((_, res) => { - res.sendStatus(204); - }) - .all(jwtdecode()) - - /** - * GET /api/logs/sources - * - * Lists the log sources available for the log viewer - */ - .get(async (req, res, next) => { - try { - const data = await internalLogViewer.listSources(res.locals.access); - res.status(200).send(data); - } catch (err) { - debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); - next(err); - } - }); - -/** - * /api/logs/tail - */ -router - .route("/tail") - .options((_, res) => { - res.sendStatus(204); - }) - .all(jwtdecode()) - - /** - * GET /api/logs/tail - * - * Retrieve the last N lines of a log source - */ - .get(async (req, res, next) => { - try { - const data = await validator( - { - required: ["type"], - additionalProperties: false, - properties: { - type: { - type: "string", - enum: ["system", "letsencrypt", "host"], - }, - host_type: { - anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }], - }, - host_id: { - anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }], - }, - channel: { - anyOf: [{ type: "null" }, { type: "string", enum: ["access", "error"] }], - }, - lines: { - anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: 1000 }], - }, - level: { - anyOf: [ - { type: "null" }, - { type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] }, - ], - }, - search: { - anyOf: [{ type: "null" }, { type: "string", minLength: 1, maxLength: 200 }], - }, - }, - }, - { - type: req.query.type, - host_type: typeof req.query.host_type === "string" ? req.query.host_type : null, - host_id: typeof req.query.host_id !== "undefined" ? req.query.host_id : null, - channel: typeof req.query.channel === "string" ? req.query.channel : null, - lines: typeof req.query.lines !== "undefined" ? req.query.lines : null, - level: typeof req.query.level === "string" ? req.query.level : null, - search: typeof req.query.search === "string" ? req.query.search : null, - }, - ); - - const result = await internalLogViewer.tail(res.locals.access, data); - res.status(200).send(result); - } catch (err) { - debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); - next(err); - } - }); - -export default router; diff --git a/backend/schema/components/log-sources-object.json b/backend/schema/components/log-sources-object.json deleted file mode 100644 index e077f1a0f9..0000000000 --- a/backend/schema/components/log-sources-object.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "type": "object", - "description": "Available log sources for the log viewer", - "required": ["system", "letsencrypt", "hosts"], - "additionalProperties": false, - "properties": { - "system": { - "type": "object", - "properties": { - "label": { - "type": "string", - "example": "System" - } - } - }, - "letsencrypt": { - "type": "object", - "properties": { - "label": { - "type": "string", - "example": "Let's Encrypt" - } - } - }, - "hosts": { - "type": "object", - "required": ["proxy", "redirection", "dead", "stream"], - "additionalProperties": false, - "properties": { - "proxy": { - "$ref": "#/$defs/host-option-list" - }, - "redirection": { - "$ref": "#/$defs/host-option-list" - }, - "dead": { - "$ref": "#/$defs/host-option-list" - }, - "stream": { - "$ref": "#/$defs/host-option-list" - } - }, - "example": { - "proxy": [{ "id": 1, "label": "example.com" }], - "redirection": [], - "dead": [], - "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }] - } - } - }, - "$defs": { - "host-option-list": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "label"], - "properties": { - "id": { - "$ref": "../common.json#/properties/id" - }, - "label": { - "type": "string", - "example": "example.com" - } - } - } - } - } -} diff --git a/backend/schema/components/log-tail-object.json b/backend/schema/components/log-tail-object.json deleted file mode 100644 index a897f08fcf..0000000000 --- a/backend/schema/components/log-tail-object.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "type": "object", - "description": "The last N lines of a log source", - "required": ["lines", "size", "truncated", "exists"], - "additionalProperties": false, - "properties": { - "lines": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."] - }, - "size": { - "type": "integer", - "description": "Size in bytes of the underlying log file", - "example": 4096 - }, - "truncated": { - "type": "boolean", - "description": "True when the file is larger than the maximum amount of data scanned per request", - "example": false - }, - "exists": { - "type": "boolean", - "description": "False when the underlying log file does not exist yet", - "example": true - } - } -} diff --git a/backend/schema/paths/logs/sources/get.json b/backend/schema/paths/logs/sources/get.json deleted file mode 100644 index 2556986999..0000000000 --- a/backend/schema/paths/logs/sources/get.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "operationId": "getLogSources", - "summary": "Get available log sources", - "tags": ["logs"], - "security": [ - { - "bearerAuth": ["admin"] - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "system": { "label": "System" }, - "letsencrypt": { "label": "Let's Encrypt" }, - "hosts": { - "proxy": [{ "id": 1, "label": "example.com" }], - "redirection": [], - "dead": [], - "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }] - } - } - } - }, - "schema": { - "$ref": "../../../components/log-sources-object.json" - } - } - } - } - } -} diff --git a/backend/schema/paths/logs/tail/get.json b/backend/schema/paths/logs/tail/get.json deleted file mode 100644 index 2e99ede574..0000000000 --- a/backend/schema/paths/logs/tail/get.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "operationId": "getLogTail", - "summary": "Get the last N lines of a log source", - "tags": ["logs"], - "security": [ - { - "bearerAuth": ["admin"] - } - ], - "parameters": [ - { - "in": "query", - "name": "type", - "required": true, - "description": "Which log source to read", - "schema": { - "type": "string", - "enum": ["system", "letsencrypt", "host"] - } - }, - { - "in": "query", - "name": "host_type", - "description": "Required when type=host", - "schema": { - "type": "string", - "enum": ["proxy", "redirection", "dead", "stream"] - } - }, - { - "in": "query", - "name": "host_id", - "description": "Required when type=host", - "schema": { - "type": "integer", - "minimum": 1 - } - }, - { - "in": "query", - "name": "channel", - "description": "Required when type=host - which log file to read (not to be confused with host_type=stream)", - "schema": { - "type": "string", - "enum": ["access", "error"] - } - }, - { - "in": "query", - "name": "lines", - "description": "Number of lines to return from the end of the file", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 200 - } - }, - { - "in": "query", - "name": "level", - "description": "Only applicable to type=system", - "schema": { - "type": "string", - "enum": ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] - } - }, - { - "in": "query", - "name": "search", - "description": "Case-insensitive plain-text search", - "schema": { - "type": "string", - "maxLength": 200 - } - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "lines": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."], - "size": 4096, - "truncated": false, - "exists": true - } - } - }, - "schema": { - "$ref": "../../../components/log-tail-object.json" - } - } - } - } - } -} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 6d5ba8bda3..36b6932d47 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -28,10 +28,6 @@ "name": "audit-log", "description": "Endpoints related to Audit Logs" }, - { - "name": "logs", - "description": "Endpoints for viewing system, Let's Encrypt and per-host logs" - }, { "name": "access-lists", "description": "Endpoints related to Access Lists" @@ -122,16 +118,6 @@ "$ref": "./paths/audit-log/id/get.json" } }, - "/logs/sources": { - "get": { - "$ref": "./paths/logs/sources/get.json" - } - }, - "/logs/tail": { - "get": { - "$ref": "./paths/logs/tail/get.json" - } - }, "/nginx/access-lists": { "get": { "$ref": "./paths/nginx/access-lists/get.json" diff --git a/frontend/src/Router.test.tsx b/frontend/src/Router.test.tsx deleted file mode 100644 index bd61148073..0000000000 --- a/frontend/src/Router.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; -import Router from "src/Router"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { authState } = vi.hoisted(() => ({ authState: { authenticated: true } })); - -vi.mock("src/context", () => ({ useAuthState: () => authState })); -vi.mock("src/hooks", () => ({ - useHealth: () => ({ data: { status: "OK", setup: true }, isLoading: false, isError: false }), -})); -vi.mock("src/components", () => ({ - Page: ({ children }: { children: ReactNode }) => children, - SiteContainer: ({ children }: { children: ReactNode }) => children, - SiteHeader: () => null, - SiteMenu: () => null, - SiteFooter: () => null, - LoadingPage: () =>
Loading
, - Unhealthy: () =>
Unhealthy
, - ErrorNotFound: () =>

Not found

, -})); -vi.mock("src/pages/Dashboard", () => ({ default: () =>

Dashboard

})); -vi.mock("src/pages/Login", () => ({ default: () =>

Login

})); -vi.mock("src/pages/Nginx/ProxyHosts", () => ({ default: () =>

Proxy hosts

})); - -describe("Router", () => { - beforeEach(() => { - authState.authenticated = true; - window.history.replaceState(null, "", "/"); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it.each(["/login", "/login/", "/login?next=/users#form"])( - "redirects an authenticated visit to %s to the dashboard", - async (path) => { - window.history.replaceState(null, "", path); - const replaceState = vi.spyOn(window.history, "replaceState"); - render(); - - expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible(); - expect(window.location.pathname).toBe("/"); - expect(window.location.search).toBe(""); - expect(window.location.hash).toBe(""); - expect(replaceState).toHaveBeenCalledWith(expect.anything(), "", "/"); - }, - ); - - it("shows the login form when signed out and redirects after sign-in", async () => { - authState.authenticated = false; - window.history.replaceState(null, "", "/login"); - const { rerender } = render(); - - expect(await screen.findByRole("heading", { name: "Login" })).toBeVisible(); - expect(window.location.pathname).toBe("/login"); - - authState.authenticated = true; - rerender(); - - expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible(); - await waitFor(() => expect(window.location.pathname).toBe("/")); - }); - - it.each([ - ["/", "Dashboard"], - ["/nginx/proxy", "Proxy hosts"], - ["/unknown", "Not found"], - ])("preserves the existing route for %s", async (path, heading) => { - window.history.replaceState(null, "", path); - render(); - - expect(await screen.findByRole("heading", { name: heading })).toBeVisible(); - expect(window.location.pathname).toBe(path); - }); -}); diff --git a/frontend/src/api/backend/getLogSources.ts b/frontend/src/api/backend/getLogSources.ts deleted file mode 100644 index 4bec700024..0000000000 --- a/frontend/src/api/backend/getLogSources.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as api from "./base"; -import type { LogSources } from "./models"; - -export async function getLogSources(): Promise { - return await api.get({ - url: "/logs/sources", - }); -} diff --git a/frontend/src/api/backend/getLogTail.ts b/frontend/src/api/backend/getLogTail.ts deleted file mode 100644 index 899282b326..0000000000 --- a/frontend/src/api/backend/getLogTail.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as api from "./base"; -import type { LogChannel, LogHostType, LogSourceType, LogTail } from "./models"; - -export interface GetLogTailParams { - type: LogSourceType; - hostType?: LogHostType; - hostId?: number; - channel?: LogChannel; - lines?: number; - level?: string; - search?: string; -} - -export async function getLogTail(params: GetLogTailParams): Promise { - return await api.get({ - url: "/logs/tail", - params: { ...params }, - }); -} diff --git a/frontend/src/api/backend/index.js b/frontend/src/api/backend/index.js index 2b082e19aa..0129cf882e 100644 --- a/frontend/src/api/backend/index.js +++ b/frontend/src/api/backend/index.js @@ -28,8 +28,6 @@ export * from "./getDeadHost"; export * from "./getDeadHosts"; export * from "./getHealth"; export * from "./getHostsReport"; -export * from "./getLogSources"; -export * from "./getLogTail"; export * from "./getProxyHost"; export * from "./getProxyHosts"; export * from "./getRedirectionHost"; diff --git a/frontend/src/hooks/index.js b/frontend/src/hooks/index.js index 240b942054..977bbddf94 100644 --- a/frontend/src/hooks/index.js +++ b/frontend/src/hooks/index.js @@ -11,8 +11,6 @@ export * from "./useDirectorySuggestions"; export * from "./useDnsProviders"; export * from "./useHealth"; export * from "./useHostReport"; -export * from "./useLogSources"; -export * from "./useLogTail"; export * from "./useProxyHost"; export * from "./useProxyHosts"; export * from "./useRedirectionHost"; diff --git a/frontend/src/hooks/useLogSources.ts b/frontend/src/hooks/useLogSources.ts deleted file mode 100644 index 3f77bbd1ef..0000000000 --- a/frontend/src/hooks/useLogSources.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getLogSources, type LogSources } from "src/api/backend"; - -const fetchLogSources = () => getLogSources(); - -const useLogSources = (options = {}) => { - return useQuery({ - queryKey: ["log-sources"], - queryFn: fetchLogSources, - staleTime: 30 * 1000, - ...options, - }); -}; - -export { fetchLogSources, useLogSources }; diff --git a/frontend/src/hooks/useLogTail.ts b/frontend/src/hooks/useLogTail.ts deleted file mode 100644 index d817dd0c11..0000000000 --- a/frontend/src/hooks/useLogTail.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { type GetLogTailParams, getLogTail, type LogTail } from "src/api/backend"; - -const POLL_INTERVAL_MS = 5000; - -interface UseLogTailOptions extends GetLogTailParams { - live?: boolean; -} - -// Polls for new log lines while `live` is true. React Query only runs the interval -// while the tab is focused (refetchIntervalInBackground defaults to false), so an -// idle/backgrounded browser tab never generates load. -const useLogTail = ({ live = true, ...params }: UseLogTailOptions) => { - const enabled = params.type === "host" ? Boolean(params.hostType && params.hostId && params.channel) : true; - - return useQuery({ - queryKey: ["log-tail", params], - queryFn: () => getLogTail(params), - enabled, - refetchInterval: live ? POLL_INTERVAL_MS : false, - placeholderData: (previousData) => previousData, - }); -}; - -export { useLogTail }; diff --git a/frontend/src/pages/Logs/LogLines.tsx b/frontend/src/pages/Logs/LogLines.tsx deleted file mode 100644 index 7e1d4763ca..0000000000 --- a/frontend/src/pages/Logs/LogLines.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import cn from "classnames"; -import { useEffect, useRef } from "react"; -import { T } from "src/locale"; -import styles from "./LogViewer.module.css"; - -const LEVEL_PATTERN = /\b(ERROR|FATAL|WARN|SUCCESS|COMPLETE|DEBUG|INFO)\b/; - -const levelClassName = (line: string): string | undefined => { - const match = line.match(LEVEL_PATTERN); - if (!match) { - return undefined; - } - switch (match[1]) { - case "ERROR": - case "FATAL": - return "text-danger"; - case "WARN": - return "text-warning"; - case "SUCCESS": - case "COMPLETE": - return "text-success"; - case "DEBUG": - return "text-secondary"; - default: - return undefined; - } -}; - -// How close to the bottom (in pixels) the user has to be for auto-scroll to keep -// following new lines. Scrolling further up than this to read history disables it, -// so newly polled lines never yank the viewport away from what's being read. -const AUTO_SCROLL_THRESHOLD_PX = 40; - -interface Props { - lines: string[]; -} - -export default function LogLines({ lines }: Props) { - const containerRef = useRef(null); - const stickToBottomRef = useRef(true); - - const handleScroll = () => { - const el = containerRef.current; - if (!el) { - return; - } - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - stickToBottomRef.current = distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX; - }; - - // biome-ignore lint/correctness/useExhaustiveDependencies: lines is a re-scroll trigger, not read in the body - useEffect(() => { - const el = containerRef.current; - if (el && stickToBottomRef.current) { - el.scrollTop = el.scrollHeight; - } - }, [lines]); - - if (!lines.length) { - return ( -
- -
- ); - } - - return ( -
-			{lines.map((line, idx) => (
-				
-					{line}
-				
-			))}
-		
- ); -} diff --git a/frontend/src/pages/Logs/LogViewer.module.css b/frontend/src/pages/Logs/LogViewer.module.css deleted file mode 100644 index 09cb1c88cf..0000000000 --- a/frontend/src/pages/Logs/LogViewer.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.logBox { - height: 65vh; - overflow-y: auto; - padding: 0.75rem 1rem; - margin: 0; - font-family: var(--tblr-font-monospace, ui-monospace, monospace); - font-size: 0.8125rem; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-all; -} - -.logLine { - display: block; -} diff --git a/frontend/src/pages/Logs/LogViewer.tsx b/frontend/src/pages/Logs/LogViewer.tsx deleted file mode 100644 index 728420c129..0000000000 --- a/frontend/src/pages/Logs/LogViewer.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import Alert from "react-bootstrap/Alert"; -import type { LogChannel, LogHostType, LogSourceType } from "src/api/backend"; -import { Loading } from "src/components"; -import { useLogSources, useLogTail } from "src/hooks"; -import { T, intl } from "src/locale"; -import LogLines from "./LogLines"; - -const LINES_OPTIONS = [100, 200, 500, 1000]; -const LEVEL_OPTIONS = ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"]; -const SEARCH_DEBOUNCE_MS = 300; - -const HOST_TYPES: { type: LogHostType; labelId: string }[] = [ - { type: "proxy", labelId: "proxy-hosts" }, - { type: "redirection", labelId: "redirection-hosts" }, - { type: "dead", labelId: "dead-hosts" }, - { type: "stream", labelId: "streams" }, -]; - -interface Selection { - type: LogSourceType; - hostType?: LogHostType; - hostId?: number; -} - -const encodeSelection = (selection: Selection): string => { - if (selection.type === "host") { - return `host:${selection.hostType}:${selection.hostId}`; - } - return selection.type; -}; - -const decodeSelection = (value: string): Selection => { - if (value === "system" || value === "letsencrypt") { - return { type: value }; - } - const [, hostType, hostId] = value.split(":"); - return { type: "host", hostType: hostType as LogHostType, hostId: Number(hostId) }; -}; - -const formatBytes = (bytes: number): string => { - if (bytes < 1024) { - return `${bytes} B`; - } - const units = ["KB", "MB", "GB"]; - let value = bytes / 1024; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex++; - } - return `${value.toFixed(1)} ${units[unitIndex]}`; -}; - -export default function LogViewer() { - const sourcesQuery = useLogSources(); - - const [selection, setSelection] = useState({ type: "system" }); - const [channel, setChannel] = useState("access"); - const [level, setLevel] = useState(""); - const [lines, setLines] = useState(LINES_OPTIONS[1]); - const [live, setLive] = useState(true); - const [searchInput, setSearchInput] = useState(""); - const [search, setSearch] = useState(""); - - useEffect(() => { - const handle = setTimeout(() => setSearch(searchInput.trim()), SEARCH_DEBOUNCE_MS); - return () => clearTimeout(handle); - }, [searchInput]); - - const tailQuery = useLogTail({ - type: selection.type, - hostType: selection.hostType, - hostId: selection.hostId, - channel: selection.type === "host" ? channel : undefined, - lines, - level: selection.type === "system" && level ? level : undefined, - search: search || undefined, - live, - }); - - const hostGroups = useMemo(() => { - if (!sourcesQuery.data) { - return []; - } - return HOST_TYPES.map(({ type, labelId }) => ({ - type, - labelId, - options: sourcesQuery.data.hosts[type] || [], - })).filter((group) => group.options.length > 0); - }, [sourcesQuery.data]); - - const handleDownload = () => { - const content = (tailQuery.data?.lines || []).join("\n"); - const blob = new Blob([content], { type: "text/plain" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${encodeSelection(selection).replace(/:/g, "-")}.log`; - a.click(); - window.URL.revokeObjectURL(url); - }; - - return ( -
-
-
-
-
-

- -

-
- {typeof tailQuery.data?.size === "number" && ( -
- {formatBytes(tailQuery.data.size)} -
- )} -
- -
- {selection.type === "host" && ( -
-
- - -
-
- )} - {selection.type === "system" && ( -
- -
- )} -
- setSearchInput(e.target.value)} - /> -
-
- -
-
- - - -
-
-
- - {tailQuery.isError && ( -
- {tailQuery.error?.message || "Unknown error"} -
- )} - - {tailQuery.data?.truncated && ( -
- - - -
- )} - - {sourcesQuery.isLoading || (tailQuery.isLoading && !tailQuery.data) ? ( -
- -
- ) : ( - - )} -
- ); -} diff --git a/frontend/src/pages/Logs/index.tsx b/frontend/src/pages/Logs/index.tsx deleted file mode 100644 index eb72b8e112..0000000000 --- a/frontend/src/pages/Logs/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { HasPermission } from "src/components"; -import { ADMIN, VIEW } from "src/modules/Permissions"; -import LogViewer from "./LogViewer"; - -const Logs = () => { - return ( - - - - ); -}; - -export default Logs; diff --git a/frontend/translations/ui/en.json b/frontend/translations/ui/en.json index b3af02f926..fc97c25502 100644 --- a/frontend/translations/ui/en.json +++ b/frontend/translations/ui/en.json @@ -172,6 +172,21 @@ "login.totp-title": "MFA", "login.totp-verify": "Verify", "logout-other-devices": "Doing this logs out all other devices.", + "logs": "Logs", + "logs.channel.access": "Access", + "logs.channel.error": "Error", + "logs.download": "Download", + "logs.empty": "No log entries yet.", + "logs.level": "Level", + "logs.level.all": "All Levels", + "logs.lines": "Lines", + "logs.live": "Live", + "logs.paused": "Paused", + "logs.refresh": "Refresh", + "logs.search-placeholder": "Search log…", + "logs.source": "Source", + "logs.source.system": "System", + "logs.truncated-warning": "Only the most recent portion of this file is shown.", "mfa.backup-codes-remaining": "Backup codes remaining: {count}", "mfa.backup-warning": "Save these backup codes in a secure place. Each code can only be used once.", "mfa.regenerate": "Regenerate", diff --git a/frontend/translations/ui/et.json b/frontend/translations/ui/et.json index ef4137499f..a8a50da5b8 100644 --- a/frontend/translations/ui/et.json +++ b/frontend/translations/ui/et.json @@ -143,6 +143,21 @@ "login.totp-description": "Sisesta kood autentimisäpist", "login.totp-title": "MFA", "login.totp-verify": "Kinnita", + "logs": "Logid", + "logs.channel.access": "Juurdepääs", + "logs.channel.error": "Viga", + "logs.download": "Laadi alla", + "logs.empty": "Logikirjeid pole veel.", + "logs.level": "Tase", + "logs.level.all": "Kõik tasemed", + "logs.lines": "Read", + "logs.live": "Reaalajas", + "logs.paused": "Peatatud", + "logs.refresh": "Värskenda", + "logs.search-placeholder": "Otsi logist…", + "logs.source": "Allikas", + "logs.source.system": "Süsteem", + "logs.truncated-warning": "Kuvatakse ainult faili kõige uuem osa.", "mfa.backup-codes-remaining": "Varukoode jäänud: {count}", "mfa.backup-warning": "Pane need varukoodid kindlasse kohta tallele. Iga koodi saab kasutada ainult üks kord.", "mfa.regenerate": "Loo uuesti", From 2cfd3395cf979b901cecb390dd3d78810c46b596 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Thu, 24 Sep 2026 09:50:13 +1000 Subject: [PATCH 26/55] Adds integration tests for per path access lists, fixes ipv6 jsv, and enforces host-wide access list when location access list is not set --- backend/internal/access-list.js | 88 ++--- backend/internal/nginx.js | 26 +- backend/internal/proxy-host.js | 32 -- backend/lib/validator/api.js | 3 + backend/lib/validator/index.js | 3 + backend/models/access_list.js | 4 + backend/schema/components/stream-object.json | 2 +- docker/docker-compose.ci.yml | 10 + frontend/src/components/Form/AccessField.tsx | 35 +- .../src/components/Form/LocationsFields.tsx | 1 + frontend/src/locale/src/en.json | 6 + frontend/src/locale/src/et.json | 6 + scripts/ci/fulltest-cypress | 3 +- .../cypress/e2e/api/AccessListLocations.cy.js | 301 ++++++++++++++++++ test/docker/Dockerfile.website | 6 + test/docker/nginx/conf.d/website123.conf | 29 ++ test/docker/nginx/nginx.conf | 30 ++ test/docker/www/404.html | 9 + test/docker/www/dashboard.html | 9 + test/docker/www/index.html | 9 + test/docker/www/profile.html | 9 + 21 files changed, 518 insertions(+), 103 deletions(-) create mode 100644 test/cypress/e2e/api/AccessListLocations.cy.js create mode 100644 test/docker/Dockerfile.website create mode 100644 test/docker/nginx/conf.d/website123.conf create mode 100644 test/docker/nginx/nginx.conf create mode 100644 test/docker/www/404.html create mode 100644 test/docker/www/dashboard.html create mode 100644 test/docker/www/index.html create mode 100644 test/docker/www/profile.html diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index bac0c1a410..1fdd65b6ab 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -42,9 +42,11 @@ const getProxyHostsUsingAccessListInLocations = async (accessListId) => { [`%"access_list_id":${accessListId}%`], ); } - // knex raw() returns [rows, metadata] for MySQL - const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; - return rows || []; + // knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite + if (!Array.isArray(result)) { + return result?.rows || []; + } + return (Array.isArray(result[0]) ? result[0] : result) || []; }; const internalAccessList = { @@ -233,26 +235,6 @@ const internalAccessList = { .whereIn("id", locationHostIds) .allowGraph(proxyHostModel.defaultAllowGraph) .withGraphFetched("[owner, certificate, access_list.[clients,items]]"); - for (const host of locationHosts) { - // Fetch access lists for locations - if (host.locations?.length) { - for (let i = 0; i < host.locations.length; i++) { - const loc = host.locations[i]; - if (loc.access_list_id && loc.access_list_id > 0) { - const locAccessList = await accessListModel - .query() - .allowGraph("[clients,items]") - .where("is_deleted", 0) - .andWhere("id", loc.access_list_id) - .withGraphFetched("[clients,items]") - .first(); - if (locAccessList) { - host.locations[i].access_list = locAccessList; - } - } - } - } - } await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts); } } @@ -338,50 +320,38 @@ const internalAccessList = { }); // 2. update any proxy hosts that were using it (ignoring permissions) - if (row.proxy_hosts) { + const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id)); + if (affectedHostIds.size) { await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 }); - - // 3. reconfigure those hosts, then reload nginx - // set the access_list_id to zero for these items - row.proxy_hosts.map((_val, idx) => { - row.proxy_hosts[idx].access_list_id = 0; - return true; - }); - - await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); } - // Also handle proxy hosts that reference this access list in their locations JSON + // Also clear it from any proxy host locations using it, these will then inherit the host's access list const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id); - if (locationHostRows?.length) { - const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => { - return !row.proxy_hosts?.find((h) => h.id === id); - }); - if (locationHostIds.length) { - // Clear the access_list_id in locations JSON for these hosts - for (const hostId of locationHostIds) { - const host = await proxyHostModel.query().where("id", hostId).first(); - if (host?.locations) { - const updatedLocations = host.locations.map((loc) => { - if (loc.access_list_id === row.id) { - return { ...loc, access_list_id: 0 }; - } - return loc; - }); - await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations }); + for (const { id: hostId } of locationHostRows) { + const host = await proxyHostModel.query().where("id", hostId).first(); + if (host?.locations?.some((loc) => loc.access_list_id === row.id)) { + const updatedLocations = host.locations.map((loc) => { + if (loc.access_list_id === row.id) { + return { ...loc, access_list_id: 0 }; } - } - - // Re-fetch and regenerate configs - const locationHosts = await proxyHostModel.query() - .where("is_deleted", 0) - .whereIn("id", locationHostIds) - .allowGraph(proxyHostModel.defaultExpand) - .withGraphFetched("[owner, certificate, access_list.[clients,items]]"); - await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts); + return loc; + }); + await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations }); + affectedHostIds.add(hostId); } } + // 3. reconfigure those hosts from fresh rows, then reload nginx + if (affectedHostIds.size) { + const affectedHosts = await proxyHostModel + .query() + .where("is_deleted", 0) + .whereIn("id", [...affectedHostIds]) + .allowGraph(proxyHostModel.defaultAllowGraph) + .withGraphFetched("[owner, certificate, access_list.[clients,items]]"); + await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts); + } + await internalNginx.reload(); // delete the htpasswd file diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 4b0b2b3c6c..a50e0780dc 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -6,6 +6,7 @@ import _ from "lodash"; import errs from "../lib/error.js"; import utils from "../lib/utils.js"; import { debug, nginx as logger } from "../logger.js"; +import accessListModel from "../models/access_list.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -168,6 +169,24 @@ const internalNginx = { host.locations[i], ); + // A location with its own access list overrides the host's, + // otherwise it inherits the host's access list + let locationAccessList = null; + if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) { + locationAccessList = await accessListModel + .query() + .where("is_deleted", 0) + .andWhere("id", locationCopy.access_list_id) + .withGraphFetched("[clients,items]") + .first(); + } + if (locationAccessList) { + locationCopy.access_list = locationAccessList; + } else { + locationCopy.access_list_id = host.access_list_id; + locationCopy.access_list = host.access_list; + } + if (locationCopy.forward_host.indexOf("/") > -1) { const splitted = locationCopy.forward_host.split("/"); @@ -179,7 +198,9 @@ const internalNginx = { } }; - locationRendering().then(() => resolve(renderedLocations)); + locationRendering() + .then(() => resolve(renderedLocations)) + .catch(reject); }); }, @@ -271,6 +292,9 @@ const internalNginx = { debug(logger, `Could not write ${filename}:`, err.message); reject(new errs.ConfigurationError(err.message)); }); + }).catch((err) => { + debug(logger, `Could not render locations for ${filename}:`, err.message); + reject(new errs.ConfigurationError(err.message)); }); }); }, diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 6bd0be1a66..eac0c148aa 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -2,7 +2,6 @@ import _ from "lodash"; import errs from "../lib/error.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import utils from "../lib/utils.js"; -import accessListModel from "../models/access_list.js"; import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; @@ -13,33 +12,6 @@ const omissions = () => { return ["is_deleted", "owner.is_deleted"]; }; -/** - * Fetches access lists for each location that has its own access_list_id. - * Attaches the expanded access_list object (with clients and items) to each location. - * - * @param {Object} host - * @returns {Promise} - */ -const fetchLocationAccessLists = async (host) => { - if (!host.locations?.length) { - return; - } - for (let i = 0; i < host.locations.length; i++) { - const loc = host.locations[i]; - if (loc.access_list_id && loc.access_list_id > 0) { - const accessList = await accessListModel - .query() - .where("is_deleted", 0) - .andWhere("id", loc.access_list_id) - .withGraphFetched("[clients,items]") - .first(); - if (accessList) { - host.locations[i].access_list = accessList; - } - } - } -}; - const internalProxyHost = { /** * @param {Access} access @@ -112,7 +84,6 @@ const internalProxyHost = { }); }) .then(async (row) => { - await fetchLocationAccessLists(row); // Configure nginx return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => { return row; @@ -242,7 +213,6 @@ const internalProxyHost = { // No need to add nginx config if host is disabled return row; } - await fetchLocationAccessLists(row); // Configure nginx return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => { row.meta = new_meta; @@ -373,8 +343,6 @@ const internalProxyHost = { enabled: 1, }); - await fetchLocationAccessLists(row); - // Configure nginx await internalNginx.configure(proxyHostModel, "proxy_host", row); diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js index f4981a80c9..0597ef635b 100644 --- a/backend/lib/validator/api.js +++ b/backend/lib/validator/api.js @@ -1,3 +1,4 @@ +import net from "node:net"; import Ajv from "ajv/dist/2020.js"; import errs from "../error.js"; @@ -9,6 +10,8 @@ const ajv = new Ajv({ coerceTypes: true, }); +ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) }); + /** * @param {Object} schema * @param {Object} payload diff --git a/backend/lib/validator/index.js b/backend/lib/validator/index.js index 5d9f8f38a8..767353f61f 100644 --- a/backend/lib/validator/index.js +++ b/backend/lib/validator/index.js @@ -1,3 +1,4 @@ +import net from "node:net"; import Ajv from "ajv/dist/2020.js"; import _ from "lodash"; import commonDefinitions from "../../schema/common.json" with { type: "json" }; @@ -14,6 +15,8 @@ const ajv = new Ajv({ schemas: [commonDefinitions], }); +ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) }); + /** * * @param {Object} schema diff --git a/backend/models/access_list.js b/backend/models/access_list.js index 427d447d62..192f045b9b 100644 --- a/backend/models/access_list.js +++ b/backend/models/access_list.js @@ -31,6 +31,10 @@ class AccessList extends Model { $parseDatabaseJson(json) { const thisJson = super.$parseDatabaseJson(json); + // Postgres returns COUNT() as a string + if (typeof thisJson.proxy_host_count === "string") { + thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10); + } return convertIntFieldsToBool(thisJson, boolFields); } diff --git a/backend/schema/components/stream-object.json b/backend/schema/components/stream-object.json index 602073ceca..3a1cd52101 100644 --- a/backend/schema/components/stream-object.json +++ b/backend/schema/components/stream-object.json @@ -44,7 +44,7 @@ }, { "type": "string", - "format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$" + "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$" }, { "type": "string", diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml index 1bb3c7450b..be6f517d3d 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -38,6 +38,16 @@ services: - website2.example.com - website3.example.com + examplesite: + image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}" + build: + context: ../test/docker + dockerfile: Dockerfile.website + expose: + - "80/tcp" + networks: + - fulltest + stepca: image: nginxproxymanager/testca volumes: diff --git a/frontend/src/components/Form/AccessField.tsx b/frontend/src/components/Form/AccessField.tsx index 1c1004e711..ef955a033f 100644 --- a/frontend/src/components/Form/AccessField.tsx +++ b/frontend/src/components/Form/AccessField.tsx @@ -1,4 +1,4 @@ -import { IconLock, IconLockOpen2 } from "@tabler/icons-react"; +import { IconArrowBackUp, IconLock, IconLockOpen2 } from "@tabler/icons-react"; import { Field, useFormikContext } from "formik"; import type { ReactNode } from "react"; import Select, { type ActionMeta, components, type OptionProps } from "react-select"; @@ -32,8 +32,16 @@ interface Props { name?: string; label?: string; onFormChange?: (value: number) => void; + // When set, the 0 option inherits the host's access list instead of being public + inheritHost?: boolean; } -export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId", onFormChange }: Props) { +export function AccessField({ + name = "accessListId", + label = "access-list", + id = "accessListId", + onFormChange, + inheritHost = false, +}: Props) { const { locale } = useLocaleState(); const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); const { setFieldValue } = useFormikContext(); @@ -60,13 +68,22 @@ export function AccessField({ name = "accessListId", label = "access-list", id = icon: , })) || []; - // Public option - options?.unshift({ - value: 0, - label: intl.formatMessage({ id: "access-list.public" }), - subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }), - icon: , - }); + // Public or inherit option + options?.unshift( + inheritHost + ? { + value: 0, + label: intl.formatMessage({ id: "access-list.inherit" }), + subLabel: intl.formatMessage({ id: "access-list.inherit.subtitle" }), + icon: , + } + : { + value: 0, + label: intl.formatMessage({ id: "access-list.public" }), + subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }), + icon: , + }, + ); return ( diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 9c4d8b3a8a..a0f64c2393 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -312,6 +312,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { label="access-list" id={`locations-access-list-${row.id}`} onFormChange={(value) => handleAccessListChange(row.id, value)} + inheritHost /> {advVisible.includes(row.id) && (
diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index da8765a5e8..11d09ea711 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -71,6 +71,12 @@ "access-list.help.rules-order": { "defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined." }, + "access-list.inherit": { + "defaultMessage": "Inherit from Proxy Host" + }, + "access-list.inherit.subtitle": { + "defaultMessage": "Uses the proxy host's access list" + }, "access-list.pass-auth": { "defaultMessage": "Pass Auth to Upstream" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index 989a153aa1..316ede752e 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -71,6 +71,12 @@ "access-list.help.rules-order": { "defaultMessage": "Luba ja keela reeglid rakenduvad selles järjekorras, milles need on kirjas." }, + "access-list.inherit": { + "defaultMessage": "Päri puhverserverilt" + }, + "access-list.inherit.subtitle": { + "defaultMessage": "Kasutab puhverserveri juurdepääsuloendit" + }, "access-list.pass-auth": { "defaultMessage": "Edasta autentimine sihtserverile" }, diff --git a/scripts/ci/fulltest-cypress b/scripts/ci/fulltest-cypress index 8fb896967c..a43f420eb0 100755 --- a/scripts/ci/fulltest-cypress +++ b/scripts/ci/fulltest-cypress @@ -65,7 +65,8 @@ rm -rf "${LOCAL_RESOLVE}" printf "nameserver %s\noptions ndots:0" "${DNSROUTER_IP}" > "${LOCAL_RESOLVE}" # bring up all remaining containers, except cypress! -docker compose up -d --remove-orphans stepca squid +docker compose build examplesite +docker compose up -d --remove-orphans --no-build stepca squid examplesite docker compose pull db-mysql || true # ok to fail docker compose pull db-postgres || true # ok to fail docker compose pull authentik authentik-redis authentik-ldap || true # ok to fail diff --git a/test/cypress/e2e/api/AccessListLocations.cy.js b/test/cypress/e2e/api/AccessListLocations.cy.js new file mode 100644 index 0000000000..2ea2742ef8 --- /dev/null +++ b/test/cypress/e2e/api/AccessListLocations.cy.js @@ -0,0 +1,301 @@ +/// + +describe('Per-path Access Lists', () => { + const domain = 'website3.example.com'; + + const alpha = { + name: 'Path Alpha', + username: 'alpha-user', + password: 'alpha-pass', + }; + + const beta = { + name: 'Path Beta', + username: 'beta-user', + password: 'beta-pass', + }; + + let token; + let alphaListId; + let betaListId; + let hostId; + + /** + * Requests a path on the proxy host directly (bypassing squid) + * and yields the HTTP status code and response body + * + * @param {string} path + * @param {object} [creds] + * @param {string} creds.username + * @param {string} creds.password + */ + const request = (path, creds) => { + const auth = creds ? `-u '${creds.username}:${creds.password}'` : ''; + return cy.exec(`curl --noproxy '*' -s -w '\n%{http_code}' ${auth} http://${domain}${path}`) + .then((result) => { + expect(result.exitCode).to.eq(0); + const lines = result.stdout.trim().split('\n'); + const status = lines.pop(); + return { status: status, body: lines.join('\n') }; + }); + }; + + // nginx reloads are signalled and return immediately, so the old + // config can still be served for a moment after an API change. + // Retry until the expected response is seen. + const waitForResponse = (path, creds, check, description) => { + // Copy now, the callback runs later and creds may have been changed by then + const credsCopy = creds ? { ...creds } : null; + let last = null; + cy.waitUntil(() => request(path, credsCopy).then((res) => { + last = res; + return check(res); + }), { + timeout: 15000, + interval: 500, + errorMsg: () => `${path} did not return ${description}, last status: ${last?.status}`, + }); + }; + + const expectStatus = (path, creds, status) => { + waitForResponse(path, creds, (res) => res.status === status, status); + }; + + // Also checks the body so we know the page came from examplesite + // and not the NPM default site + const expectPage = (path, creds, text) => { + waitForResponse(path, creds, (res) => res.status === '200' && res.body.includes(text), `200 with "${text}"`); + }; + + const createAccessList = (list) => { + return cy.task('backendApiPost', { + token: token, + path: '/api/nginx/access-lists', + data: { + name: list.name, + satisfy_any: false, + pass_auth: false, + items: [ + { + username: list.username, + password: list.password, + } + ], + clients: [], + } + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/access-lists', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + return cy.wrap(data.id); + }); + }; + + const location = (path, accessListId) => { + return { + path: path, + forward_scheme: 'http', + forward_host: 'examplesite', + forward_port: 80, + access_list_id: accessListId, + }; + }; + + before(() => { + cy.resetUsers(); + cy.getToken().then((tok) => { + token = tok; + }); + }); + + it('Should be able to create multiple access lists with credentials', () => { + createAccessList(alpha).then((id) => { + alphaListId = id; + }); + createAccessList(beta).then((id) => { + betaListId = id; + expect(betaListId).to.not.equal(alphaListId); + }); + }); + + it('Should be able to create a proxy host with a different access list per location', () => { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/proxy-hosts', + data: { + domain_names: [domain], + forward_scheme: 'http', + forward_host: 'examplesite', + forward_port: 80, + access_list_id: 0, + certificate_id: 0, + meta: { + dns_challenge: false + }, + advanced_config: '', + locations: [ + location('/dashboard', alphaListId), + location('/profile', betaListId), + ], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false + } + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + hostId = data.id; + expect(data).to.have.property('enabled', true); + expect(data).to.have.property('access_list_id', 0); + expect(data.locations).to.have.length(2); + expect(data.locations[0]).to.have.property('access_list_id', alphaListId); + expect(data.locations[1]).to.have.property('access_list_id', betaListId); + }); + }); + + it('Should persist the location access lists on the proxy host', () => { + cy.task('backendApiGet', { + token: token, + path: `/api/nginx/proxy-hosts/${hostId}`, + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/nginx/proxy-hosts/{hostID}', data); + expect(data.locations[0]).to.have.property('path', '/dashboard'); + expect(data.locations[0]).to.have.property('access_list_id', alphaListId); + expect(data.locations[1]).to.have.property('path', '/profile'); + expect(data.locations[1]).to.have.property('access_list_id', betaListId); + }); + }); + + it('Should not require auth for the host root', () => { + expectPage('/', null, 'this is the index page'); + }); + + it('Should only accept the alpha credentials on /dashboard', () => { + expectStatus('/dashboard', null, '401'); + expectStatus('/dashboard', beta, '401'); + expectPage('/dashboard', alpha, 'this is the dashboard page'); + }); + + it('Should only accept the beta credentials on /profile', () => { + expectStatus('/profile', null, '401'); + expectStatus('/profile', alpha, '401'); + expectPage('/profile', beta, 'this is the profile page'); + }); + + it('Should apply access list credential changes to locations using it', () => { + const newPassword = 'alpha-pass-changed'; + + cy.task('backendApiPut', { + token: token, + path: `/api/nginx/access-lists/${alphaListId}`, + data: { + name: alpha.name, + satisfy_any: false, + pass_auth: false, + items: [ + { + username: alpha.username, + password: newPassword, + } + ], + clients: [], + } + }).then((data) => { + cy.validateSwaggerSchema('put', 200, '/nginx/access-lists/{listID}', data); + expect(data).to.have.property('id', alphaListId); + }); + + expectStatus('/dashboard', alpha, '401'); + expectPage('/dashboard', { username: alpha.username, password: newPassword }, 'this is the dashboard page'); + alpha.password = newPassword; + + // Other locations are unaffected + expectStatus('/profile', null, '401'); + expectPage('/profile', beta, 'this is the profile page'); + }); + + it('Should inherit the host access list on locations without their own', () => { + // Host uses beta, /dashboard overrides with alpha, /missing has none and should inherit beta + cy.task('backendApiPut', { + token: token, + path: `/api/nginx/proxy-hosts/${hostId}`, + data: { + access_list_id: betaListId, + locations: [ + location('/dashboard', alphaListId), + location('/profile', betaListId), + location('/missing', 0), + ], + } + }).then((data) => { + // No swagger validation here: with a host access list set, the expanded + // access_list in the response has no proxy_host_count, which the schema requires + expect(data).to.have.property('access_list_id', betaListId); + expect(data.locations[2]).to.have.property('access_list_id', 0); + }); + + expectStatus('/', null, '401'); + expectStatus('/', alpha, '401'); + expectPage('/', beta, 'this is the index page'); + + // examplesite returns 404 for this path, once past the access list + expectStatus('/missing', null, '401'); + expectStatus('/missing', alpha, '401'); + expectStatus('/missing', beta, '404'); + + expectStatus('/dashboard', beta, '401'); + expectPage('/dashboard', alpha, 'this is the dashboard page'); + }); + + it('Should remove a deleted access list from the host and locations using it', () => { + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/access-lists/${betaListId}`, + }).then((data) => { + cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data); + expect(data).to.be.equal(true); + }); + + cy.task('backendApiGet', { + token: token, + path: `/api/nginx/proxy-hosts/${hostId}`, + }).then((data) => { + expect(data).to.have.property('access_list_id', 0); + expect(data.locations[0]).to.have.property('access_list_id', alphaListId); + expect(data.locations[1]).to.have.property('access_list_id', 0); + expect(data.locations[2]).to.have.property('access_list_id', 0); + }); + + expectPage('/', null, 'this is the index page'); + expectPage('/profile', null, 'this is the profile page'); + expectStatus('/missing', null, '404'); + + // Remaining location access list must still be enforced + expectStatus('/dashboard', null, '401'); + expectPage('/dashboard', alpha, 'this is the dashboard page'); + }); + + it('Should be able to delete the proxy host and remaining access list', () => { + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/proxy-hosts/${hostId}`, + }).then((data) => { + cy.validateSwaggerSchema('delete', 200, '/nginx/proxy-hosts/{hostID}', data); + expect(data).to.be.equal(true); + }); + + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/access-lists/${alphaListId}`, + }).then((data) => { + cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data); + expect(data).to.be.equal(true); + }); + }); + +}); diff --git a/test/docker/Dockerfile.website b/test/docker/Dockerfile.website new file mode 100644 index 0000000000..311272d8c4 --- /dev/null +++ b/test/docker/Dockerfile.website @@ -0,0 +1,6 @@ +FROM nginx:stable + +RUN rm -rf /etc/nginx/conf.d +COPY nginx /etc/nginx +COPY www /www +RUN chown -R nginx:nginx /www diff --git a/test/docker/nginx/conf.d/website123.conf b/test/docker/nginx/conf.d/website123.conf new file mode 100644 index 0000000000..656f3ade42 --- /dev/null +++ b/test/docker/nginx/conf.d/website123.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name website1.example.com website2.example.com website3.example.com; + root /www; + index index.html; + + # redirect requests for .html URLs to their extensionless form + if ($request_uri ~ ^/index\.html(\?.*)?$) { + return 301 /$1; + } + if ($request_uri ~ ^/(.+)\.html(\?.*)?$) { + return 301 /$1$2; + } + + location / { + try_files $uri $uri.html $uri/ =404; + } + + error_page 404 /404.html; + location = /404.html { + internal; + } + + # deny access to .htaccess files, if Apache's document root + # concurs with nginx's one + location ~ /\.ht { + deny all; + } +} diff --git a/test/docker/nginx/nginx.conf b/test/docker/nginx/nginx.conf new file mode 100644 index 0000000000..82366ca042 --- /dev/null +++ b/test/docker/nginx/nginx.conf @@ -0,0 +1,30 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log notice; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + server_tokens off; + + log_format custom_combined '$remote_addr - $remote_user [$time_local] "$host" "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"'; + + access_log /var/log/nginx/access.log custom_combined; + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + gzip on; + + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; # Includes Docker subnet + set_real_ip_from 192.168.0.0/16; + + include include/*.conf; + include conf.d/*.conf; + include /sites/*/nginx.conf; +} diff --git a/test/docker/www/404.html b/test/docker/www/404.html new file mode 100644 index 0000000000..389d3cafb1 --- /dev/null +++ b/test/docker/www/404.html @@ -0,0 +1,9 @@ + + + 404 + + + 404 not found + + + diff --git a/test/docker/www/dashboard.html b/test/docker/www/dashboard.html new file mode 100644 index 0000000000..d26099e8f9 --- /dev/null +++ b/test/docker/www/dashboard.html @@ -0,0 +1,9 @@ + + + Dashboard + + + this is the dashboard page + + + diff --git a/test/docker/www/index.html b/test/docker/www/index.html new file mode 100644 index 0000000000..3a190926cb --- /dev/null +++ b/test/docker/www/index.html @@ -0,0 +1,9 @@ + + + Index + + + this is the index page + + + diff --git a/test/docker/www/profile.html b/test/docker/www/profile.html new file mode 100644 index 0000000000..1058ca4a15 --- /dev/null +++ b/test/docker/www/profile.html @@ -0,0 +1,9 @@ + + + Profile + + + this is the profile page + + + From 9136faed296564fdadf9b136d6e5eff84f048b69 Mon Sep 17 00:00:00 2001 From: Zoey2936 <75573284+Zoey2936@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:58:53 +0000 Subject: [PATCH 27/55] update nginx 1756 patch hash Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7422571a08..065edf4caf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,7 +64,7 @@ RUN git-clone-commit.sh https://github.com/nginx/nginx "$NGINX_VER" /src/nginx & echo "73fdee62748f1624f87015a951a2480fd0d4fe566a81d92b852b51536d954b91 /src/nginx/1.patch" | sha256sum -c - && \ git apply /src/nginx/1.patch && \ wget -q https://patch-diff.githubusercontent.com/raw/nginx/nginx/pull/1756.patch -O /src/nginx/2.patch && \ - echo "01b09c898ab5c02f3d7bc061b00aa3950692550d6351bcea0c0148cb3f2dd0e8 /src/nginx/2.patch" | sha256sum -c - && \ + echo "2c86aca949907e0d9299108ea603d7ca7baebcb23da82fc56acd6330bf552617 /src/nginx/2.patch" | sha256sum -c - && \ git apply /src/nginx/2.patch && \ wget -q https://patch-diff.githubusercontent.com/raw/nginx/nginx/pull/1333.patch -O /src/nginx/3.patch && \ echo "01bf75b130b8f91075ec913a400a8debfab6da0ac609711c7d412ddbe59dd898 /src/nginx/3.patch" | sha256sum -c - && \ From 6450777e1fadf3b10fb52df6d482ce741ca487e7 Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 08:46:40 +0200 Subject: [PATCH 28/55] run goaccess in external assets mode Signed-off-by: Zoey --- rootfs/etc/dinit.d/goaccess | 2 +- rootfs/usr/local/nginx/conf/conf.d/npmplus.conf | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/rootfs/etc/dinit.d/goaccess b/rootfs/etc/dinit.d/goaccess index 49de708a53..de761e3c1b 100644 --- a/rootfs/etc/dinit.d/goaccess +++ b/rootfs/etc/dinit.d/goaccess @@ -1,5 +1,5 @@ type = process -command = goaccess --no-global-config --num-tests=0 --tz=$TZ --time-format=%H:%M:%S --date-format=%d/%b/%Y --log-format="[%d:%t %^] %v %h %T \"%r\" %s %b \"%R\" \"%u\" \"%e\" \"%M\" %K %k %g %C" --unix-socket=/run/goaccess.sock --log-file=/data/nginx/logs/access.log --real-time-html --output=/tmp/goa/index.html --db-path=/data/goaccess/data --restore --persist --browsers-file=/etc/goaccess/browsers.list --browsers-file=/etc/goaccess/podcast.list $/GOACLA +command = goaccess --no-global-config --num-tests=0 --tz=$TZ --time-format=%H:%M:%S --date-format=%d/%b/%Y --log-format="[%d:%t %^] %v %h %T \"%r\" %s %b \"%R\" \"%u\" \"%e\" \"%M\" %K %k %g %C" --unix-socket=/run/goaccess.sock --log-file=/data/nginx/logs/access.log --real-time-html --external-assets --output=/tmp/goa/index.html --db-path=/data/goaccess/data --restore --persist --browsers-file=/etc/goaccess/browsers.list --browsers-file=/etc/goaccess/podcast.list $/GOACLA term-signal = INT restart = yes options = shares-console diff --git a/rootfs/usr/local/nginx/conf/conf.d/npmplus.conf b/rootfs/usr/local/nginx/conf/conf.d/npmplus.conf index 0227f15bad..c673a7a496 100644 --- a/rootfs/usr/local/nginx/conf/conf.d/npmplus.conf +++ b/rootfs/usr/local/nginx/conf/conf.d/npmplus.conf @@ -76,9 +76,9 @@ server { } # based on https://github.com/xavier-hernandez/goaccess-for-nginxproxymanager/blob/main/resources/nginx/nginx.conf - location = /goaccess { + location ~ ^/goaccess(\.css|\.js)?$ { auth_request /api/auth/admin; - more_set_headers "Content-Security-Policy: default-src 'none'; script-src 'unsafe-eval'; script-src-elem 'unsafe-inline'; style-src-elem 'unsafe-inline'; style-src-attr 'unsafe-inline'; connect-src 'self'; font-src 'self' data:; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests;"; + more_set_headers "Content-Security-Policy: default-src 'none'; script-src 'unsafe-eval'; script-src-elem 'self' 'unsafe-hashes' 'sha256-kbHtQyYDQKz4SWMQ8OHVol3EC0t3tHEJFPCSwNG9NxQ='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; connect-src 'self'; font-src 'self' data:; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests;"; more_set_headers "Cache-Control: no-store"; include proxy-headers.conf; proxy_set_header Accept-Encoding ""; @@ -87,9 +87,10 @@ server { } root /tmp/goa; - try_files /index.html =502; + try_files $uri /index.html =502; sub_filter 'WebSocket(str)' 'WebSocket(window.location.toString().split("#")[0].replace(window.location.protocol, window.location.protocol == "https:" ? "wss:" : "ws:"))'; sub_filter_once on; + sub_filter_types text/javascript; } location ~ ^/api/docs(/|$) { From 215be03ea56691a621f2f8692496cb20ab68de8f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:46:11 +0000 Subject: [PATCH 29/55] dep updates --- backend/package.json | 2 +- backend/pnpm-lock.yaml | 12 +-- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 172 ++++++++++++++++++++-------------------- 4 files changed, 94 insertions(+), 94 deletions(-) diff --git a/backend/package.json b/backend/package.json index 24f1a7fef2..d712cc71c7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,7 @@ "otplib": "13.5.0", "pg": "8.23.0", "swagger-ui-express": "5.0.1", - "undici": "8.11.0" + "undici": "8.11.2" }, "devDependencies": { "@apidevtools/swagger-parser": "13.1.0", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index aeea34333e..9d5c39b132 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -69,8 +69,8 @@ importers: specifier: 5.0.1 version: 5.0.1(express@5.2.1) undici: - specifier: 8.11.0 - version: 8.11.0 + specifier: 8.11.2 + version: 8.11.2 devDependencies: '@apidevtools/swagger-parser': specifier: 13.1.0 @@ -989,8 +989,8 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - undici@8.11.0: - resolution: {integrity: sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==} + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} unpipe@1.0.0: @@ -1021,7 +1021,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 js-yaml: 5.4.2 - undici: 8.11.0 + undici: 8.11.2 '@apidevtools/openapi-schemas@2.1.0': {} @@ -1910,7 +1910,7 @@ snapshots: undici-types@8.9.0: {} - undici@8.11.0: {} + undici@8.11.2: {} unpipe@1.0.0: {} diff --git a/frontend/package.json b/frontend/package.json index 0b4091f9da..70f8972c1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,6 @@ "devDependencies": { "@biomejs/biome": "2.5.14", "@vitejs/plugin-react": "6.1.1", - "vite": "8.3.0" + "vite": "8.3.1" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index dc7011ea40..d36fee1c41 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -74,10 +74,10 @@ importers: version: 2.5.14 '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.3.0) + version: 6.1.1(vite@8.3.1) vite: - specifier: 8.3.0 - version: 8.3.0 + specifier: 8.3.1 + version: 8.3.1 packages: @@ -105,8 +105,8 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.8': - resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true @@ -277,8 +277,8 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@oxc-project/types@0.149.0': - resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -314,98 +314,98 @@ packages: react: '>=16.14.0' react-dom: '>=16.14.0' - '@rolldown/binding-android-arm-eabi@1.2.8': - resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + '@rolldown/binding-android-arm-eabi@1.2.10': + resolution: {integrity: sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.8': - resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + '@rolldown/binding-android-arm64@1.2.10': + resolution: {integrity: sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.8': - resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + '@rolldown/binding-darwin-arm64@1.2.10': + resolution: {integrity: sha512-UbEfXq/AqGNgRTV3ik+X/iR6mUxu2QdYAadwRxJWquUGnW6gDqdP1FtLtFXRow7RJx0ssRwi80XAPr4r+4DtsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.8': - resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + '@rolldown/binding-darwin-x64@1.2.10': + resolution: {integrity: sha512-7f5h17q5KZVx/ji1vb8OTq31ch1O2I7K8NPIr44GkyWTApXMIsmhWqZfgpOH10xeauqghDAvGlZktasCkcF6Eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.8': - resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + '@rolldown/binding-freebsd-x64@1.2.10': + resolution: {integrity: sha512-ynOk/eEYhC6ZB2xCGvKrEOwE58oBy9LnrAqtkrDF9Fz1VTaNdGZTsV0VarJdhPwb+sOJTGjCLwcuyRJZ1dnMcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': - resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.10': + resolution: {integrity: sha512-ERrAs185meZZhGan7a4l3RiiJK1ArSDlHdST++uvSxe+FDbR4TwUPahT/cbZJvaG6fIpDpF78surN+tX708Y4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.8': - resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + '@rolldown/binding-linux-arm64-gnu@1.2.10': + resolution: {integrity: sha512-KN7OHKD0J3jy1UzBwZWPxpwhODf9IARUIJcrH+yLYKOcmegZ8luEUM38lDP1bDVj40yP6PsSzCqOJF76vljFnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.8': - resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + '@rolldown/binding-linux-arm64-musl@1.2.10': + resolution: {integrity: sha512-8l9wP8O+wa8zD6iw6egSfzVtu7oZVfH3hlUsMM4MwbLMhxleqeoXbZzjddyK3YyNlwLhqznq3tF7PkNJ8T/V2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.8': - resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.10': + resolution: {integrity: sha512-SeXNKeQzA5kLhz/J0CH6ZP0/HJ3v1xm/0YbiYpE0kK7emfRC2OIGGIaE14xzkISEGv2aYuUSpiLiU5Gbq+OI0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.8': - resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.10': + resolution: {integrity: sha512-mtht0nR+y8/hart4175Ll15w7lY8dg7CtQ+j2FDNTsDRspOWTK/2V3l0aj9sIj7XmvqxT8Yli/wq22e7feTTWg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.8': - resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + '@rolldown/binding-linux-x64-gnu@1.2.10': + resolution: {integrity: sha512-FSM94nGd55NYo48usCyM/nHfUKRnqc9+b0vJNuKV0oCCpIp/OGims7rO1Nv/DkFkt0S/s2rxsJ2kkS8J3HcpeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.8': - resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + '@rolldown/binding-linux-x64-musl@1.2.10': + resolution: {integrity: sha512-C3YxNB16myRLs7o+B+6PnQ6jBsdIS4+AE4Ah8glVGhDpEv9AOvxhZ/1duAb4B0UGczEK/lBbccksd8VI+p6zfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.8': - resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + '@rolldown/binding-openharmony-arm64@1.2.10': + resolution: {integrity: sha512-571TlE/F1eeTjjdjYAMMMPs1Mfv3MtX6s3+ZKVU6HiUjZ5Njc6c/qzNy/8K3zALTZnaw3JQVYrHxvNfjm43KAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.8': - resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + '@rolldown/binding-win32-arm64-msvc@1.2.10': + resolution: {integrity: sha512-QXW+ZWaiqs2c7Fi++D/SsW07LTPcUrncxcskJGfGNBoaLik1IU6fJymz4HsqwEO0u5Iq11yTO0B/mc4cPk7jrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.8': - resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + '@rolldown/binding-win32-x64-msvc@1.2.10': + resolution: {integrity: sha512-5FQFGgah17YeMtG1Yd5a+rMxQpTksyNXxRtKz06FVTaQw3RKYUJQbUoKk0/5jrXBpDo+7makNP7UHA2LQyH64A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -622,8 +622,8 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} js-tokens@4.0.0: @@ -894,8 +894,8 @@ packages: engines: {node: '>= 0.4'} hasBin: true - rolldown@1.2.8: - resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + rolldown@1.2.10: + resolution: {integrity: sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -962,8 +962,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - vite@8.3.0: - resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1022,7 +1022,7 @@ snapshots: '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -1041,7 +1041,7 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.8': + '@babel/parser@7.29.9': dependencies: '@babel/types': 7.29.8 @@ -1050,7 +1050,7 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@babel/types': 7.29.8 '@babel/traverse@7.29.8': @@ -1058,7 +1058,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 '@babel/types': 7.29.8 debug: 4.4.3 @@ -1230,7 +1230,7 @@ snapshots: jsbi: 4.3.2 optional: true - '@oxc-project/types@0.149.0': {} + '@oxc-project/types@0.151.0': {} '@popperjs/core@2.11.8': {} @@ -1271,49 +1271,49 @@ snapshots: uncontrollable: 8.0.4(react@19.3.0) warning: 4.0.3 - '@rolldown/binding-android-arm-eabi@1.2.8': + '@rolldown/binding-android-arm-eabi@1.2.10': optional: true - '@rolldown/binding-android-arm64@1.2.8': + '@rolldown/binding-android-arm64@1.2.10': optional: true - '@rolldown/binding-darwin-arm64@1.2.8': + '@rolldown/binding-darwin-arm64@1.2.10': optional: true - '@rolldown/binding-darwin-x64@1.2.8': + '@rolldown/binding-darwin-x64@1.2.10': optional: true - '@rolldown/binding-freebsd-x64@1.2.8': + '@rolldown/binding-freebsd-x64@1.2.10': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + '@rolldown/binding-linux-arm-gnueabihf@1.2.10': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.8': + '@rolldown/binding-linux-arm64-gnu@1.2.10': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.8': + '@rolldown/binding-linux-arm64-musl@1.2.10': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.8': + '@rolldown/binding-linux-ppc64-gnu@1.2.10': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.8': + '@rolldown/binding-linux-s390x-gnu@1.2.10': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.8': + '@rolldown/binding-linux-x64-gnu@1.2.10': optional: true - '@rolldown/binding-linux-x64-musl@1.2.8': + '@rolldown/binding-linux-x64-musl@1.2.10': optional: true - '@rolldown/binding-openharmony-arm64@1.2.8': + '@rolldown/binding-openharmony-arm64@1.2.10': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.8': + '@rolldown/binding-win32-arm64-msvc@1.2.10': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.8': + '@rolldown/binding-win32-x64-msvc@1.2.10': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1380,10 +1380,10 @@ snapshots: '@types/warning@3.0.4': {} - '@vitejs/plugin-react@6.1.1(vite@8.3.0)': + '@vitejs/plugin-react@6.1.1(vite@8.3.1)': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0 + vite: 8.3.1 aria-hidden@1.2.6: dependencies: @@ -1497,7 +1497,7 @@ snapshots: is-arrayish@0.2.1: {} - is-core-module@2.16.2: + is-core-module@2.17.0: dependencies: hasown: 2.0.4 @@ -1746,30 +1746,30 @@ snapshots: resolve@1.22.12: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.2 + is-core-module: 2.17.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rolldown@1.2.8: + rolldown@1.2.10: dependencies: - '@oxc-project/types': 0.149.0 + '@oxc-project/types': 0.151.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.8 - '@rolldown/binding-android-arm64': 1.2.8 - '@rolldown/binding-darwin-arm64': 1.2.8 - '@rolldown/binding-darwin-x64': 1.2.8 - '@rolldown/binding-freebsd-x64': 1.2.8 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 - '@rolldown/binding-linux-arm64-gnu': 1.2.8 - '@rolldown/binding-linux-arm64-musl': 1.2.8 - '@rolldown/binding-linux-ppc64-gnu': 1.2.8 - '@rolldown/binding-linux-s390x-gnu': 1.2.8 - '@rolldown/binding-linux-x64-gnu': 1.2.8 - '@rolldown/binding-linux-x64-musl': 1.2.8 - '@rolldown/binding-openharmony-arm64': 1.2.8 - '@rolldown/binding-win32-arm64-msvc': 1.2.8 - '@rolldown/binding-win32-x64-msvc': 1.2.8 + '@rolldown/binding-android-arm-eabi': 1.2.10 + '@rolldown/binding-android-arm64': 1.2.10 + '@rolldown/binding-darwin-arm64': 1.2.10 + '@rolldown/binding-darwin-x64': 1.2.10 + '@rolldown/binding-freebsd-x64': 1.2.10 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.10 + '@rolldown/binding-linux-arm64-gnu': 1.2.10 + '@rolldown/binding-linux-arm64-musl': 1.2.10 + '@rolldown/binding-linux-ppc64-gnu': 1.2.10 + '@rolldown/binding-linux-s390x-gnu': 1.2.10 + '@rolldown/binding-linux-x64-gnu': 1.2.10 + '@rolldown/binding-linux-x64-musl': 1.2.10 + '@rolldown/binding-openharmony-arm64': 1.2.10 + '@rolldown/binding-win32-arm64-msvc': 1.2.10 + '@rolldown/binding-win32-x64-msvc': 1.2.10 rooks@9.9.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: @@ -1825,12 +1825,12 @@ snapshots: dependencies: react: 19.3.0 - vite@8.3.0: + vite@8.3.1: dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.28 - rolldown: 1.2.8 + rolldown: 1.2.10 tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 From 99538ae27d8e65da98fc6ef4f986ed78d73f4293 Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 14:35:47 +0200 Subject: [PATCH 30/55] fix reload crash Signed-off-by: Zoey --- Dockerfile | 2 ++ rootfs/usr/local/bin/cron-ech.sh | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 065edf4caf..92c48a8c07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,6 +84,7 @@ RUN git-clone-commit.sh https://github.com/nginx/nginx "$NGINX_VER" /src/nginx & wget -q https://raw.githubusercontent.com/openresty/openresty/master/patches/nginx/"$ORP_VER"/nginx-"$ORP_VER"-resolver_hosts.patch -O /src/nginx/8.patch && \ echo "7a3e9ebe4fafaef0a90773ed093bed83c3e753af5983718b0aee50881c32151b /src/nginx/8.patch" | sha256sum -c - && \ git apply /src/nginx/8.patch && \ + sed -i "s|ngx_destroy_pool(r->hosts->pool);|r->hosts->pool->log = r->log; &|" /src/nginx/src/core/ngx_resolver.c && \ wget -q https://raw.githubusercontent.com/openresty/openresty/master/patches/nginx/"$ORP_VER"/nginx-"$ORP_VER"-upstream_pipelining.patch -O /src/nginx/9.patch && \ echo "f147c9724a0ad33084a3cb51acdafbd4dbd2c40ba5840af1684b7b9595b24ae2 /src/nginx/9.patch" | sha256sum -c - && \ git apply /src/nginx/9.patch && \ @@ -249,6 +250,7 @@ RUN apk upgrade --no-cache -a && \ luarocks-5.1 install lua-resty-http && \ luarocks-5.1 install lua-resty-string && \ luarocks-5.1 install lua-resty-openssl && \ + sed -i 's|^local legacy_nids = {}$|C.ERR_clear_error()\n&|' /usr/local/share/lua/5.1/resty/openssl/pkey.lua && \ \ git config --global advice.detachedHead false && \ git config --global init.defaultBranch main && \ diff --git a/rootfs/usr/local/bin/cron-ech.sh b/rootfs/usr/local/bin/cron-ech.sh index b08402a5f9..6196a76cd4 100755 --- a/rootfs/usr/local/bin/cron-ech.sh +++ b/rootfs/usr/local/bin/cron-ech.sh @@ -12,11 +12,11 @@ if [ -s /data/tls/ech/cron.sh ]; then [ -s "$file" ] && echo "ssl_ech_file $file;" >> /data/tls/ech/nginx.conf.tmp done mv /data/tls/ech/nginx.conf.tmp /data/tls/ech/nginx.conf - nginx -s reload + curl -sSfL --out-null --unix-socket /run/nginx-control.sock -X PATCH http://localhost/1/control/config elif [ -s /data/tls/ech/nginx.conf ]; then rm -f /data/tls/ech/*.ech jq -n '{current: [], previous: []}' > /data/tls/ech/config-ids.json : > /data/tls/ech/nginx.conf - nginx -s reload + curl -sSfL --out-null --unix-socket /run/nginx-control.sock -X PATCH http://localhost/1/control/config fi From cf33cd402b4c7192a23e55a7b123284e5e1ff09a Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 15:01:20 +0200 Subject: [PATCH 31/55] Update README.md Signed-off-by: Zoey --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d2487604e0..e498ac3e70 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,10 @@ upstream cu_mybackend { - Because ECH requires advertising your public key via an HTTPS DNS record, your `cron.sh` must push the newly generated config to your DNS provider. - There is an example cron.sh script for Cloudflare in the repository: [`ech-cron-cloudflare-example.sh`](ech-cron-cloudflare-example.sh). You can adapt this script, add your API tokens, define your zones/records, and place its contents into `/opt/npmplus/tls/ech/cron.sh`. - By default, the container will run your `cron.sh` script and reload nginx on container start and then every hour after container start. You can change this interval by setting the `ECH_ROTATION_INTERVAL` environment variable in your `compose.yaml`. -- I recommend you to use your servers hostname/PTR record as public name. The "identifier" is only used as part of the filename. +- I recommend you to use your servers hostname/PTR record as public name, make sure a (dead) host with a valid cert for your public name exists. The "identifier" is only used as part of the filename. - I recommend you to only use one ECH key shared for all your hosts. If you configure multiple ECH keys then only the one with the alphabetically first "identifier" will be used in the retry_configs response. - Do not set HTTPS records for FQDNs which use a CNAME record, but set them for the CNAME target, as only the HTTPS record of the CNAME target will be used by chromium. -- Deleting/clearing the cron.sh will disable ECH +- Deleting/clearing the cron.sh will disable ECH, but you still need to remove ECH from the HTTPS records yourself ## Geoblocking example (mainly community support) From ed1186e112f1b440f2a3d67708be244fb9a2c82c Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 15:29:56 +0200 Subject: [PATCH 32/55] improve trim() calls Signed-off-by: Zoey --- backend/internal/backup-codes.js | 6 +++--- backend/internal/mfa.js | 16 ++++++-------- backend/internal/token.js | 4 ++-- backend/internal/totp.js | 4 +--- backend/routes/users.js | 6 +++--- backend/schema/common.json | 4 +++- .../schema/components/proxy-host-object.json | 4 +++- .../components/redirection-host-object.json | 1 + backend/schema/components/stream-object.json | 1 + .../schema/paths/settings/settingID/put.json | 3 ++- .../src/components/Form/DomainNamesField.jsx | 2 +- frontend/src/modals/AccessListModal.jsx | 3 ++- .../src/modals/CustomCertificateModal.jsx | 2 +- frontend/src/modals/DeadHostModal.jsx | 12 ++--------- frontend/src/modals/ProxyHostModal.jsx | 21 +++++++++---------- frontend/src/modals/RedirectionHostModal.jsx | 13 +++--------- frontend/src/modals/StreamModal.jsx | 17 ++++++--------- frontend/src/modals/UserModal.jsx | 1 + frontend/src/pages/Settings/DefaultSite.jsx | 2 +- frontend/src/pages/Setup/index.jsx | 1 + 20 files changed, 53 insertions(+), 70 deletions(-) diff --git a/backend/internal/backup-codes.js b/backend/internal/backup-codes.js index 60e43cb91e..448fd52111 100644 --- a/backend/internal/backup-codes.js +++ b/backend/internal/backup-codes.js @@ -68,12 +68,12 @@ const internalBackupCodes = { * @returns {Promise} */ verify: async (userId, code) => { - const codeTrim = code.trim().toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1"); + const normalizedCode = code.toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1"); for (const row of await codesOf(userId)) { const match = row.secret.startsWith("$2") - ? await bcrypt.compare(codeTrim, row.secret) - : await verify(codeTrim, row.secret); + ? await bcrypt.compare(normalizedCode, row.secret) + : await verify(normalizedCode, row.secret); // Remove used backup code, only the request that removes it counts as used if (match) return (await authModel.query().findById(row.id).delete()) === 1; } diff --git a/backend/internal/mfa.js b/backend/internal/mfa.js index d475389095..a9d561bab5 100644 --- a/backend/internal/mfa.js +++ b/backend/internal/mfa.js @@ -93,15 +93,13 @@ const internalMfa = { * @returns {Promise} */ verifyForLogin: async (userId, token) => { - const tokenTrim = token.trim(); - // TOTP codes are 6 chars, backup codes are 8 chars - if (tokenTrim.length === 6) { - return await totp.verifyCode(userId, tokenTrim); + if (token.length === 6) { + return await totp.verifyCode(userId, token); } - if (tokenTrim.length === 8) { - return await backupCodes.verify(userId, tokenTrim); + if (token.length === 8) { + return await backupCodes.verify(userId, token); } return false; @@ -158,12 +156,10 @@ const internalMfa = { throw new errs.ValidationError("MFA is not enabled"); } - const tokenTrim = token.trim(); - - if (tokenTrim.length !== 6) { + if (token.length !== 6) { throw new errs.ValidationError("Invalid verification code"); } - if (!(await totp.verifyCode(userId, tokenTrim))) { + if (!(await totp.verifyCode(userId, token))) { throw new errs.ValidationError("Invalid verification code"); } diff --git a/backend/internal/token.js b/backend/internal/token.js index 1ae3396460..1c4487a85a 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -49,7 +49,7 @@ export default { const hasMfa = await mfa.isAnyEnabled(user.id); if (hasMfa) { if (data.code) { - const validCode = await mfa.verifyForLogin(user.id, data.code); + const validCode = await mfa.verifyForLogin(user.id, data.code.trim()); if (!validCode) { throw new errs.PermissionError(ERROR_MESSAGE_INVALID_CODE, ERROR_MESSAGE_INVALID_CODE_I18N); } @@ -214,7 +214,7 @@ export default { consumedChallenges.set(tokenData.jti, tokenData.exp); // Verify TOTP code - if (!(await mfa.verifyForLogin(userId, code))) { + if (!(await mfa.verifyForLogin(userId, code.trim()))) { consumedChallenges.delete(tokenData.jti); throw new errs.PermissionError(ERROR_MESSAGE_INVALID_CODE, ERROR_MESSAGE_INVALID_CODE_I18N); } diff --git a/backend/internal/totp.js b/backend/internal/totp.js index 76fab3081c..32ae274031 100644 --- a/backend/internal/totp.js +++ b/backend/internal/totp.js @@ -78,9 +78,7 @@ const internalTotp = { throw new errs.ValidationError("TOTP setup has expired"); } - const codeTrim = code.trim(); - - const result = await verify({ token: codeTrim, secret: pending.secret }); + const result = await verify({ token: code, secret: pending.secret }); if (!result.valid) { throw new errs.ValidationError("Invalid verification code"); } diff --git a/backend/routes/users.js b/backend/routes/users.js index 3429cbef26..e45e1e8a7a 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -346,7 +346,7 @@ router */ .delete(async (req, res, next) => { try { - const code = typeof req.query.code === "string" ? req.query.code : null; + const code = typeof req.query.code === "string" ? req.query.code.trim() : null; if (!code) throw new errs.ValidationError("Missing required parameter: code"); await internalMfa.disableTotp(res.locals.access, req.params.user_id, code); res.status(200).send(true); @@ -374,7 +374,7 @@ router .post(async (req, res, next) => { try { const { code } = apiValidator(getValidationSchema("/users/{userID}/mfa/totp/enable", "post"), req.body); - const result = await internalMfa.enableTotp(res.locals.access, req.params.user_id, code); + const result = await internalMfa.enableTotp(res.locals.access, req.params.user_id, code.trim()); const data = await internalToken.getFreshToken(res.locals.access, true); res.cookie("__Host-Http-token", data.token, { signed: true, @@ -408,7 +408,7 @@ router .post(async (req, res, next) => { try { const { code } = apiValidator(getValidationSchema("/users/{userID}/mfa/backup-codes", "post"), req.body); - const result = await internalMfa.regenerateBackupCodes(res.locals.access, req.params.user_id, code); + const result = await internalMfa.regenerateBackupCodes(res.locals.access, req.params.user_id, code.trim()); const data = await internalToken.getFreshToken(res.locals.access, true); res.cookie("__Host-Http-token", data.token, { signed: true, diff --git a/backend/schema/common.json b/backend/schema/common.json index 8145dad8a9..6bca5839b8 100644 --- a/backend/schema/common.json +++ b/backend/schema/common.json @@ -85,7 +85,8 @@ "items": { "type": "string", "minLength": 1, - "maxLength": 253 + "maxLength": 253, + "pattern": "^\\S+$" }, "example": [ "example.com", @@ -191,6 +192,7 @@ "address": { "type": "string", "maxLength": 255, + "pattern": "^\\S*$", "example": "192.168.0.11" }, "access_items": { diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 6cd63971fc..9f20843ec4 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -57,6 +57,7 @@ "forward_host": { "type": "string", "maxLength": 255, + "pattern": "^\\S*$", "example": "127.0.0.1" }, "forward_port": { @@ -199,7 +200,8 @@ }, "path": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^\\S+$" }, "location_type": { "type": "string", diff --git a/backend/schema/components/redirection-host-object.json b/backend/schema/components/redirection-host-object.json index 8e3f7671dc..fa3081f54c 100644 --- a/backend/schema/components/redirection-host-object.json +++ b/backend/schema/components/redirection-host-object.json @@ -61,6 +61,7 @@ "type": "string", "minLength": 1, "maxLength": 253, + "pattern": "^\\S+$", "example": "jc21.com" }, "preserve_path": { diff --git a/backend/schema/components/stream-object.json b/backend/schema/components/stream-object.json index f690fc3a05..930f425c6d 100644 --- a/backend/schema/components/stream-object.json +++ b/backend/schema/components/stream-object.json @@ -43,6 +43,7 @@ "type": "string", "minLength": 1, "maxLength": 253, + "pattern": "^\\S+$", "example": "127.0.0.1" }, "forwarding_port": { diff --git a/backend/schema/paths/settings/settingID/put.json b/backend/schema/paths/settings/settingID/put.json index 99f0684f87..b890f9016b 100644 --- a/backend/schema/paths/settings/settingID/put.json +++ b/backend/schema/paths/settings/settingID/put.json @@ -52,7 +52,8 @@ "additionalProperties": false, "properties": { "redirect": { - "type": "string" + "type": "string", + "pattern": "^\\S*$" }, "html": { "type": "string" diff --git a/frontend/src/components/Form/DomainNamesField.jsx b/frontend/src/components/Form/DomainNamesField.jsx index 0e48d94ae4..2565f10874 100644 --- a/frontend/src/components/Form/DomainNamesField.jsx +++ b/frontend/src/components/Form/DomainNamesField.jsx @@ -15,7 +15,7 @@ export function DomainNamesField({ const { setFieldValue } = useFormikContext(); const handleChange = (v, _actionMeta) => { - const doms = v?.map((i) => i.value); + const doms = v?.map((i) => i.value.trim()); void setFieldValue(name, doms); onChange?.(doms); }; diff --git a/frontend/src/modals/AccessListModal.jsx b/frontend/src/modals/AccessListModal.jsx index aeba7c42ca..4b5d2430b5 100644 --- a/frontend/src/modals/AccessListModal.jsx +++ b/frontend/src/modals/AccessListModal.jsx @@ -51,6 +51,7 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }) => { const { ...payload } = { id: id === "new" ? undefined : id, ...values, + name: values.name.trim(), }; // Filter out "items" to only use the "username" and "password" fields @@ -62,7 +63,7 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }) => { // Filter out "clients" to only use the "directive" and "address" fields payload.clients = (values.clients || []).map((i) => ({ directive: i.directive, - address: i.address, + address: i.address.trim(), })); setAccessList(payload, { diff --git a/frontend/src/modals/CustomCertificateModal.jsx b/frontend/src/modals/CustomCertificateModal.jsx index 87125f382e..0fbc2e531b 100644 --- a/frontend/src/modals/CustomCertificateModal.jsx +++ b/frontend/src/modals/CustomCertificateModal.jsx @@ -48,7 +48,7 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove, cert, provid } else { // Create certificate with the specified provider const newCert = await createCertificate({ - niceName, + niceName: niceName.trim(), provider, }); diff --git a/frontend/src/modals/DeadHostModal.jsx b/frontend/src/modals/DeadHostModal.jsx index 7105dddb1c..067a321753 100644 --- a/frontend/src/modals/DeadHostModal.jsx +++ b/frontend/src/modals/DeadHostModal.jsx @@ -35,16 +35,8 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }) => { setErrorMsg(null); const meta = { ...(values.meta || {}) }; - if (typeof meta.directory === "string") { - const trimmed = meta.directory.trim(); - if (trimmed) { - meta.directory = trimmed; - } else { - delete meta.directory; - } - } else { - delete meta.directory; - } + meta.directory = typeof meta.directory === "string" ? meta.directory.trim() : ""; + if (!meta.directory) delete meta.directory; const { ...payload } = { id: id === "new" ? undefined : id, diff --git a/frontend/src/modals/ProxyHostModal.jsx b/frontend/src/modals/ProxyHostModal.jsx index f9b055736c..187d9bfecf 100644 --- a/frontend/src/modals/ProxyHostModal.jsx +++ b/frontend/src/modals/ProxyHostModal.jsx @@ -45,7 +45,12 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove globalAclIds = []; } const locations = (values.locations || []).map((loc) => { - const newLoc = { ...loc }; + const newLoc = { + ...loc, + path: loc.path.trim(), + forwardHost: loc.forwardHost.trim(), + npmplusAuthRequestUpstream: loc.npmplusAuthRequestUpstream?.trim(), + }; if (loc.npmplusAccessListType === "global" || loc.npmplusAccessListType === "public") { newLoc.npmplusAccessListIds = []; } @@ -53,16 +58,8 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove }); const meta = { ...(values.meta || {}) }; - if (typeof meta.directory === "string") { - const trimmed = meta.directory.trim(); - if (trimmed) { - meta.directory = trimmed; - } else { - delete meta.directory; - } - } else { - delete meta.directory; - } + meta.directory = typeof meta.directory === "string" ? meta.directory.trim() : ""; + if (!meta.directory) delete meta.directory; const { ...payload } = { id: id === "new" || isClone ? undefined : id, @@ -70,7 +67,9 @@ const ProxyHostModal = EasyModal.create(({ id, isClone = false, visible, remove meta, npmplusAccessListIds: globalAclIds, locations, + forwardHost: values.forwardHost.trim(), forwardPort: values.forwardPort || null, + npmplusAuthRequestUpstream: values.npmplusAuthRequestUpstream.trim(), }; setProxyHost(payload, { diff --git a/frontend/src/modals/RedirectionHostModal.jsx b/frontend/src/modals/RedirectionHostModal.jsx index be1532b216..d1736819c0 100644 --- a/frontend/src/modals/RedirectionHostModal.jsx +++ b/frontend/src/modals/RedirectionHostModal.jsx @@ -36,21 +36,14 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }) => { setErrorMsg(null); const meta = { ...(values.meta || {}) }; - if (typeof meta.directory === "string") { - const trimmed = meta.directory.trim(); - if (trimmed) { - meta.directory = trimmed; - } else { - delete meta.directory; - } - } else { - delete meta.directory; - } + meta.directory = typeof meta.directory === "string" ? meta.directory.trim() : ""; + if (!meta.directory) delete meta.directory; const { ...payload } = { id: id === "new" ? undefined : id, ...values, meta, + forwardDomainName: values.forwardDomainName.trim(), }; setRedirectionHost(payload, { diff --git a/frontend/src/modals/StreamModal.jsx b/frontend/src/modals/StreamModal.jsx index f655a5abca..196737b2de 100644 --- a/frontend/src/modals/StreamModal.jsx +++ b/frontend/src/modals/StreamModal.jsx @@ -27,22 +27,17 @@ const StreamModal = EasyModal.create(({ id, visible, remove }) => { setErrorMsg(null); const meta = { ...(values.meta || {}) }; - if (typeof meta.directory === "string") { - const trimmed = meta.directory.trim(); - if (trimmed) { - meta.directory = trimmed; - } else { - delete meta.directory; - } - } else { - delete meta.directory; - } + meta.directory = typeof meta.directory === "string" ? meta.directory.trim() : ""; + if (!meta.directory) delete meta.directory; const { ...payload } = { id: id === "new" ? undefined : id, ...values, meta, - forwardingPort: values.forwardingPort || null, + incomingPort: values.incomingPort.trim(), + forwardingHost: values.forwardingHost.trim(), + forwardingPort: values.forwardingPort?.trim() || null, + npmplusDescription: values.npmplusDescription.trim(), }; setStream(payload, { diff --git a/frontend/src/modals/UserModal.jsx b/frontend/src/modals/UserModal.jsx index b55c5b1426..726fc9c1a5 100644 --- a/frontend/src/modals/UserModal.jsx +++ b/frontend/src/modals/UserModal.jsx @@ -47,6 +47,7 @@ const UserModal = EasyModal.create(({ id, visible, remove }) => { id: id === "new" ? undefined : id, roles: [], ...values, + name: values.name.trim(), }; if (data?.id === currentUser?.id) { diff --git a/frontend/src/pages/Settings/DefaultSite.jsx b/frontend/src/pages/Settings/DefaultSite.jsx index 59c79e4bc2..19121c134f 100644 --- a/frontend/src/pages/Settings/DefaultSite.jsx +++ b/frontend/src/pages/Settings/DefaultSite.jsx @@ -22,7 +22,7 @@ export default function DefaultSite() { id: "default-site", value: values.value, meta: { - redirect: values.redirect, + redirect: values.redirect.trim(), html: values.html, status: values.status ? Number(values.status) : undefined, }, diff --git a/frontend/src/pages/Setup/index.jsx b/frontend/src/pages/Setup/index.jsx index 97e07beef4..a307af017a 100644 --- a/frontend/src/pages/Setup/index.jsx +++ b/frontend/src/pages/Setup/index.jsx @@ -24,6 +24,7 @@ export default function Setup() { const { password, ...payload } = { ...values, + name: values.name.trim(), ...{ auth: { type: "password", From 7ec110fd7032f778b977d19da32e5d0bae2fdcfe Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 15:47:21 +0200 Subject: [PATCH 33/55] add cert adn access list button to the start page Signed-off-by: Zoey --- backend/internal/report.js | 8 ++- backend/schema/paths/reports/hosts/get.json | 14 ++++- frontend/src/pages/Dashboard/index.jsx | 70 +++++++++++++++++++-- frontend/translations/ui/en.json | 2 + frontend/translations/ui/nl.json | 2 + 5 files changed, 87 insertions(+), 9 deletions(-) diff --git a/backend/internal/report.js b/backend/internal/report.js index 2e2bbc7030..5b26bbb558 100644 --- a/backend/internal/report.js +++ b/backend/internal/report.js @@ -1,3 +1,5 @@ +import internalAccessList from "./access-list.js"; +import internalCertificate from "./certificate.js"; import internalDeadHost from "./dead-host.js"; import internalProxyHost from "./proxy-host.js"; import internalRedirectionHost from "./redirection-host.js"; @@ -11,14 +13,16 @@ const internalReport = { getHostsReport: async (access) => { const userId = access.token.getUserId(1); - const [proxy, redirection, stream, dead] = await Promise.all([ + const [proxy, redirection, stream, dead, access_list, certificate] = await Promise.all([ internalProxyHost.getCount(userId, access.visibility), internalRedirectionHost.getCount(userId, access.visibility), internalStream.getCount(userId, access.visibility), internalDeadHost.getCount(userId, access.visibility), + internalAccessList.getCount(userId, access.visibility), + internalCertificate.getCount(userId, access.visibility), ]); - return { proxy, redirection, stream, dead }; + return { proxy, redirection, stream, dead, access_list, certificate }; }, }; diff --git a/backend/schema/paths/reports/hosts/get.json b/backend/schema/paths/reports/hosts/get.json index b229444dc8..39f8a80d37 100644 --- a/backend/schema/paths/reports/hosts/get.json +++ b/backend/schema/paths/reports/hosts/get.json @@ -20,7 +20,9 @@ "proxy": 20, "redirection": 1, "stream": 0, - "dead": 1 + "dead": 1, + "access_list": 2, + "certificate": 15 } } }, @@ -46,6 +48,16 @@ "type": "integer", "description": "404 Hosts Count", "example": 3 + }, + "access_list": { + "type": "integer", + "description": "Access Lists Count", + "example": 2 + }, + "certificate": { + "type": "integer", + "description": "Certificates Count", + "example": 15 } } } diff --git a/frontend/src/pages/Dashboard/index.jsx b/frontend/src/pages/Dashboard/index.jsx index f53a0e1cca..70ef6e9da4 100644 --- a/frontend/src/pages/Dashboard/index.jsx +++ b/frontend/src/pages/Dashboard/index.jsx @@ -1,9 +1,17 @@ -import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react"; +import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield } from "@tabler/icons-react"; import { useNavigate } from "react-router"; import { HasPermission } from "src/components"; import { useHostReport } from "src/hooks"; import { T } from "src/locale"; -import { DEAD_HOSTS, PROXY_HOSTS, REDIRECTION_HOSTS, STREAMS, VIEW } from "src/modules/Permissions"; +import { + ACCESS_LISTS, + CERTIFICATES, + DEAD_HOSTS, + PROXY_HOSTS, + REDIRECTION_HOSTS, + STREAMS, + VIEW, +} from "src/modules/Permissions"; const Dashboard = () => { const { data: hostReport } = useHostReport(); @@ -18,7 +26,7 @@ const Dashboard = () => {
-
+ - diff --git a/frontend/translations/ui/en.json b/frontend/translations/ui/en.json index fc97c25502..d1d1f517d7 100644 --- a/frontend/translations/ui/en.json +++ b/frontend/translations/ui/en.json @@ -16,6 +16,7 @@ "access-list.satisfy-any": "Satisfy Any", "access-list.subtitle": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}} - Created: {date}", "access-lists": "Access Lists", + "access-lists.count": "{count} {count, plural, one {Access List} other {Access Lists}}", "action.add": "Add", "action.add-location": "Add Location", "action.allow": "Allow", @@ -47,6 +48,7 @@ "certificate.not-in-use": "Not Used", "certificate.renew": "Renew Certificate", "certificates": "Certificates", + "certificates.count": "{count} {count, plural, one {Certificate} other {Certificates}}", "certificates.custom": "Custom Certificate", "certificates.custom.warning": "Key files protected with a passphrase are not supported.", "certificates.dns.credentials": "Credentials File Content", diff --git a/frontend/translations/ui/nl.json b/frontend/translations/ui/nl.json index f3a28aa463..ed4d23bd6b 100644 --- a/frontend/translations/ui/nl.json +++ b/frontend/translations/ui/nl.json @@ -16,6 +16,7 @@ "access-list.satisfy-any": "Voldoen aan minstens een", "access-list.subtitle": "{users} {users, plural, one {Gebruiker} other {Gebruikers}}, {rules} {rules, plural, one {Regel} other {Regels}} - Aangemaakt: {date}", "access-lists": "Toegangslijsten", + "access-lists.count": "{count} {count, plural, one {Toegangslijst} other {Toegangslijsten}}", "action.add": "Toevoegen", "action.add-location": "Locatie toevoegen", "action.allow": "Toestaan", @@ -47,6 +48,7 @@ "certificate.not-in-use": "Niet gebruikt", "certificate.renew": "Certificaat vernieuwen", "certificates": "Certificaten", + "certificates.count": "{count} {count, plural, one {Certificaat} other {Certificaten}}", "certificates.custom": "Aangepast certificaat", "certificates.custom.warning": "Sleutels met een wachtwoordzin zijn niet ondersteund.", "certificates.dns.credentials": "Inloggegevens bestandsinhoud", From 68cd530a3ba4ea63132c8ab09c1d3af7ffa5755d Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 16:07:52 +0200 Subject: [PATCH 34/55] reorder menu Signed-off-by: Zoey --- frontend/src/components/SiteMenu.jsx | 96 ++++++++++++-------------- frontend/src/pages/Dashboard/index.jsx | 48 ++++++------- 2 files changed, 69 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/SiteMenu.jsx b/frontend/src/components/SiteMenu.jsx index fef6b79864..fff0735fbb 100644 --- a/frontend/src/components/SiteMenu.jsx +++ b/frontend/src/components/SiteMenu.jsx @@ -1,13 +1,13 @@ import { - IconBook, + IconArrowsCross, + IconBolt, + IconBoltOff, IconChartBar, - IconDeviceDesktop, + IconDisc, IconExternalLink, - IconHome, IconLock, IconSettings, IconShield, - IconUser, } from "@tabler/icons-react"; import cn from "clsx"; import React from "react"; @@ -27,45 +27,31 @@ import { const menuItems = [ { - to: "/", - icon: IconHome, - label: "dashboard", + to: "/nginx/proxy", + icon: IconBolt, + label: "proxy-hosts", + permissionSection: PROXY_HOSTS, + permission: VIEW, }, { - icon: IconDeviceDesktop, - label: "hosts", - items: [ - { - to: "/nginx/proxy", - label: "proxy-hosts", - permissionSection: PROXY_HOSTS, - permission: VIEW, - }, - { - to: "/nginx/redirection", - label: "redirection-hosts", - permissionSection: REDIRECTION_HOSTS, - permission: VIEW, - }, - { - to: "/nginx/stream", - label: "streams", - permissionSection: STREAMS, - permission: VIEW, - }, - { - to: "/nginx/404", - label: "dead-hosts", - permissionSection: DEAD_HOSTS, - permission: VIEW, - }, - ], + to: "/nginx/redirection", + icon: IconArrowsCross, + label: "redirection-hosts", + permissionSection: REDIRECTION_HOSTS, + permission: VIEW, }, { - to: "/access", - icon: IconLock, - label: "access-lists", - permissionSection: ACCESS_LISTS, + to: "/nginx/404", + icon: IconBoltOff, + label: "dead-hosts", + permissionSection: DEAD_HOSTS, + permission: VIEW, + }, + { + to: "/nginx/stream", + icon: IconDisc, + label: "streams", + permissionSection: STREAMS, permission: VIEW, }, { @@ -76,22 +62,30 @@ const menuItems = [ permission: VIEW, }, { - to: "/users", - icon: IconUser, - label: "users", - permissionSection: ADMIN, - }, - { - to: "/audit-log", - icon: IconBook, - label: "auditlogs", - permissionSection: ADMIN, + to: "/access", + icon: IconLock, + label: "access-lists", + permissionSection: ACCESS_LISTS, + permission: VIEW, }, { - to: "/settings", icon: IconSettings, label: "settings", permissionSection: ADMIN, + items: [ + { + to: "/settings", + label: "settings", + }, + { + to: "/users", + label: "users", + }, + { + to: "/audit-log", + label: "auditlogs", + }, + ], }, ]; @@ -139,7 +133,7 @@ const getMenuDropown = (item, onClick) => { aria-expanded="false" > - + {React.createElement(item.icon, { height: 24, width: 24 })} diff --git a/frontend/src/pages/Dashboard/index.jsx b/frontend/src/pages/Dashboard/index.jsx index 70ef6e9da4..236e705e62 100644 --- a/frontend/src/pages/Dashboard/index.jsx +++ b/frontend/src/pages/Dashboard/index.jsx @@ -80,100 +80,100 @@ const Dashboard = () => {
- + - + - + - +
{ e.preventDefault(); - void navigate("/certificates"); + void navigate("/access"); }} >
- - + +
- +
From 2a553d2e760c1c8223263dc47d45e17ca0b22d74 Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 16:43:07 +0200 Subject: [PATCH 35/55] merge upstream Signed-off-by: Zoey --- backend/lib/validator/api.js | 3 - backend/lib/validator/index.js | 3 - backend/models/access_list.js | 4 - backend/routes/nginx/proxy_hosts.js | 67 -- frontend/src/api/backend/getProxyHostLogs.ts | 8 - frontend/src/api/backend/index.js | 1 - frontend/src/hooks/index.js | 1 - frontend/src/hooks/useProxyHostLogs.ts | 12 - frontend/src/locale/src/en.json | 842 ------------------ frontend/src/locale/src/et.json | 842 ------------------ frontend/src/modals/HostLogsModal.tsx | 93 -- frontend/src/modals/index.js | 1 - frontend/translations/ui/en.json | 4 + frontend/translations/ui/et.json | 4 + .../cypress/e2e/api/AccessListLocations.cy.js | 301 ------- test/docker/Dockerfile.website | 6 - test/docker/nginx/conf.d/website123.conf | 29 - test/docker/nginx/nginx.conf | 30 - test/docker/www/404.html | 9 - test/docker/www/dashboard.html | 9 - test/docker/www/index.html | 9 - test/docker/www/profile.html | 9 - 22 files changed, 8 insertions(+), 2279 deletions(-) delete mode 100644 frontend/src/api/backend/getProxyHostLogs.ts delete mode 100644 frontend/src/hooks/useProxyHostLogs.ts delete mode 100644 frontend/src/locale/src/en.json delete mode 100644 frontend/src/locale/src/et.json delete mode 100644 frontend/src/modals/HostLogsModal.tsx delete mode 100644 test/cypress/e2e/api/AccessListLocations.cy.js delete mode 100644 test/docker/Dockerfile.website delete mode 100644 test/docker/nginx/conf.d/website123.conf delete mode 100644 test/docker/nginx/nginx.conf delete mode 100644 test/docker/www/404.html delete mode 100644 test/docker/www/dashboard.html delete mode 100644 test/docker/www/index.html delete mode 100644 test/docker/www/profile.html diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js index b0f9e95c2a..0ed1f29a13 100644 --- a/backend/lib/validator/api.js +++ b/backend/lib/validator/api.js @@ -1,4 +1,3 @@ -import net from "node:net"; import Ajv from "ajv/dist/2020.js"; import errs from "../error.js"; @@ -10,8 +9,6 @@ const ajv = new Ajv({ coerceTypes: true, }); -ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) }); - /** * @param {Object} schema * @param {Object} payload diff --git a/backend/lib/validator/index.js b/backend/lib/validator/index.js index d084b67b41..ebc02d0133 100644 --- a/backend/lib/validator/index.js +++ b/backend/lib/validator/index.js @@ -1,4 +1,3 @@ -import net from "node:net"; import Ajv from "ajv/dist/2020.js"; import commonDefinitions from "../../schema/common.json" with { type: "json" }; import errs from "../error.js"; @@ -14,8 +13,6 @@ const ajv = new Ajv({ schemas: [commonDefinitions], }); -ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) }); - /** * * @param {Object} schema diff --git a/backend/models/access_list.js b/backend/models/access_list.js index b9ad0ecddf..86fb3d161a 100644 --- a/backend/models/access_list.js +++ b/backend/models/access_list.js @@ -31,10 +31,6 @@ class AccessList extends Model { $parseDatabaseJson(json) { const thisJson = super.$parseDatabaseJson(json); - // Postgres returns COUNT() as a string - if (typeof thisJson.proxy_host_count === "string") { - thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10); - } return convertIntFieldsToBool(thisJson, boolFields); } diff --git a/backend/routes/nginx/proxy_hosts.js b/backend/routes/nginx/proxy_hosts.js index 02b42b59cd..7c69019c53 100644 --- a/backend/routes/nginx/proxy_hosts.js +++ b/backend/routes/nginx/proxy_hosts.js @@ -1,5 +1,4 @@ import express from "express"; -import fs from "node:fs"; import internalProxyHost from "../../internal/proxy-host.js"; import internalProxyHostAccessList from "../../internal/proxy-host-access-list.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; @@ -197,70 +196,4 @@ router } }); -/** - * Proxy-host logs - * - * /api/nginx/proxy-hosts/123/logs - */ -router - .route("/:host_id/logs") - .options((_, res) => { - res.sendStatus(204); - }) - .all(jwtdecode()) - - /** - * GET /api/nginx/proxy-hosts/123/logs - * - * Retrieve logs for a specific proxy-host - */ - .get(async (req, res, next) => { - try { - const data = await validator( - { - required: ["host_id"], - additionalProperties: false, - properties: { - host_id: { - $ref: "common#/properties/id", - }, - type: { - type: "string", - enum: ["access", "error"], - }, - }, - }, - { - host_id: req.params.host_id, - type: req.query.type || "access", - }, - ); - - const hostId = Number.parseInt(data.host_id, 10); - const logType = data.type === "error" ? "error" : "access"; - const logFile = `/data/logs/proxy-host-${hostId}_${logType}.log`; - - // Check access permission - await res.locals.access.can("proxy_hosts:get", hostId); - - let logs = ""; - if (fs.existsSync(logFile)) { - const content = fs.readFileSync(logFile, { encoding: "utf8" }); - const lines = content.split("\n"); - // Return last 1000 lines to avoid huge payloads - const maxLines = 1000; - if (lines.length > maxLines) { - logs = lines.slice(-maxLines).join("\n"); - } else { - logs = content; - } - } - - res.status(200).send({ logs }); - } catch (err) { - debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); - next(err); - } - }); - export default router; diff --git a/frontend/src/api/backend/getProxyHostLogs.ts b/frontend/src/api/backend/getProxyHostLogs.ts deleted file mode 100644 index 91944c9d72..0000000000 --- a/frontend/src/api/backend/getProxyHostLogs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as api from "./base"; - -export async function getProxyHostLogs(id: number, type: "access" | "error" = "access"): Promise<{ logs: string }> { - return await api.get({ - url: `/nginx/proxy-hosts/${id}/logs`, - params: { type }, - }); -} diff --git a/frontend/src/api/backend/index.js b/frontend/src/api/backend/index.js index 248fe08ed7..0129cf882e 100644 --- a/frontend/src/api/backend/index.js +++ b/frontend/src/api/backend/index.js @@ -29,7 +29,6 @@ export * from "./getDeadHosts"; export * from "./getHealth"; export * from "./getHostsReport"; export * from "./getProxyHost"; -export * from "./getProxyHostLogs"; export * from "./getProxyHosts"; export * from "./getRedirectionHost"; export * from "./getRedirectionHosts"; diff --git a/frontend/src/hooks/index.js b/frontend/src/hooks/index.js index 55f01ee292..977bbddf94 100644 --- a/frontend/src/hooks/index.js +++ b/frontend/src/hooks/index.js @@ -12,7 +12,6 @@ export * from "./useDnsProviders"; export * from "./useHealth"; export * from "./useHostReport"; export * from "./useProxyHost"; -export * from "./useProxyHostLogs"; export * from "./useProxyHosts"; export * from "./useRedirectionHost"; export * from "./useRedirectionHosts"; diff --git a/frontend/src/hooks/useProxyHostLogs.ts b/frontend/src/hooks/useProxyHostLogs.ts deleted file mode 100644 index efcda1b682..0000000000 --- a/frontend/src/hooks/useProxyHostLogs.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getProxyHostLogs } from "src/api/backend"; - -const useProxyHostLogs = (id: number, type: "access" | "error" = "access") => { - return useQuery<{ logs: string }, Error>({ - queryKey: ["proxy-host-logs", id, type], - queryFn: () => getProxyHostLogs(id, type), - staleTime: 10_000, - }); -}; - -export { useProxyHostLogs }; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json deleted file mode 100644 index 11d09ea711..0000000000 --- a/frontend/src/locale/src/en.json +++ /dev/null @@ -1,842 +0,0 @@ -{ - "2fa.backup-codes-remaining": { - "defaultMessage": "Backup codes remaining: {count}" - }, - "2fa.backup-warning": { - "defaultMessage": "Save these backup codes in a secure place. Each code can only be used once." - }, - "2fa.disable": { - "defaultMessage": "Disable Two-Factor Authentication" - }, - "2fa.disable-confirm": { - "defaultMessage": "Disable 2FA" - }, - "2fa.disable-warning": { - "defaultMessage": "Disabling two-factor authentication will make your account less secure." - }, - "2fa.disabled": { - "defaultMessage": "Disabled" - }, - "2fa.done": { - "defaultMessage": "I have saved my backup codes" - }, - "2fa.enable": { - "defaultMessage": "Enable Two-Factor Authentication" - }, - "2fa.enabled": { - "defaultMessage": "Enabled" - }, - "2fa.enter-code": { - "defaultMessage": "Enter verification code" - }, - "2fa.enter-code-disable": { - "defaultMessage": "Enter verification code to disable" - }, - "2fa.regenerate": { - "defaultMessage": "Regenerate" - }, - "2fa.regenerate-backup": { - "defaultMessage": "Regenerate Backup Codes" - }, - "2fa.regenerate-instructions": { - "defaultMessage": "Enter a verification code to generate new backup codes. Your old codes will be invalidated." - }, - "2fa.secret-key": { - "defaultMessage": "Secret Key" - }, - "2fa.setup-instructions": { - "defaultMessage": "Scan this QR code with your authenticator app, or enter the secret manually." - }, - "2fa.status": { - "defaultMessage": "Status" - }, - "2fa.title": { - "defaultMessage": "Two-Factor Authentication" - }, - "2fa.verify-enable": { - "defaultMessage": "Verify and Enable" - }, - "access-list": { - "defaultMessage": "Access List" - }, - "access-list.access-count": { - "defaultMessage": "{count} {count, plural, one {Rule} other {Rules}}" - }, - "access-list.auth-count": { - "defaultMessage": "{count} {count, plural, one {User} other {Users}}" - }, - "access-list.help-rules-last": { - "defaultMessage": "When at least 1 rule exists, this deny all rule will be added last" - }, - "access-list.help.rules-order": { - "defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined." - }, - "access-list.inherit": { - "defaultMessage": "Inherit from Proxy Host" - }, - "access-list.inherit.subtitle": { - "defaultMessage": "Uses the proxy host's access list" - }, - "access-list.pass-auth": { - "defaultMessage": "Pass Auth to Upstream" - }, - "access-list.public": { - "defaultMessage": "Publicly Accessible" - }, - "access-list.public.subtitle": { - "defaultMessage": "No basic auth required" - }, - "access-list.rule-source.placeholder": { - "defaultMessage": "192.168.1.100 or 192.168.1.0/24 or 2001:0db8::/32" - }, - "access-list.satisfy-any": { - "defaultMessage": "Satisfy Any" - }, - "access-list.subtitle": { - "defaultMessage": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}} - Created: {date}" - }, - "access-lists": { - "defaultMessage": "Access Lists" - }, - "action.add": { - "defaultMessage": "Add" - }, - "action.add-location": { - "defaultMessage": "Add Location" - }, - "action.allow": { - "defaultMessage": "Allow" - }, - "action.clear": { - "defaultMessage": "Clear" - }, - "action.close": { - "defaultMessage": "Close" - }, - "action.delete": { - "defaultMessage": "Delete" - }, - "action.deny": { - "defaultMessage": "Deny" - }, - "action.disable": { - "defaultMessage": "Disable" - }, - "action.download": { - "defaultMessage": "Download" - }, - "action.edit": { - "defaultMessage": "Edit" - }, - "action.enable": { - "defaultMessage": "Enable" - }, - "action.logs": { - "defaultMessage": "Logs" - }, - "action.permissions": { - "defaultMessage": "Permissions" - }, - "action.renew": { - "defaultMessage": "Renew" - }, - "action.view-details": { - "defaultMessage": "View Details" - }, - "auditlogs": { - "defaultMessage": "Audit Logs" - }, - "auto": { - "defaultMessage": "Auto" - }, - "cancel": { - "defaultMessage": "Cancel" - }, - "certificate": { - "defaultMessage": "Certificate" - }, - "certificate.custom-certificate": { - "defaultMessage": "Certificate" - }, - "certificate.custom-certificate-key": { - "defaultMessage": "Certificate Key" - }, - "certificate.custom-intermediate": { - "defaultMessage": "Intermediate Certificate" - }, - "certificate.in-use": { - "defaultMessage": "In Use" - }, - "certificate.none.subtitle": { - "defaultMessage": "No certificate assigned" - }, - "certificate.none.subtitle.for-http": { - "defaultMessage": "This host will not use HTTPS" - }, - "certificate.none.title": { - "defaultMessage": "None" - }, - "certificate.not-in-use": { - "defaultMessage": "Not Used" - }, - "certificate.renew": { - "defaultMessage": "Renew Certificate" - }, - "certificates": { - "defaultMessage": "Certificates" - }, - "certificates.custom": { - "defaultMessage": "Custom Certificate" - }, - "certificates.custom.warning": { - "defaultMessage": "Key files protected with a passphrase are not supported." - }, - "certificates.dns.credentials": { - "defaultMessage": "Credentials File Content" - }, - "certificates.dns.credentials-note": { - "defaultMessage": "This plugin requires a configuration file containing an API token or other credentials for your provider" - }, - "certificates.dns.credentials-warning": { - "defaultMessage": "This data will be stored as plaintext in the database and in a file!" - }, - "certificates.dns.propagation-seconds": { - "defaultMessage": "Propagation Seconds" - }, - "certificates.dns.propagation-seconds-note": { - "defaultMessage": "Leave empty to use the plugins default value. Number of seconds to wait for DNS propagation." - }, - "certificates.dns.provider": { - "defaultMessage": "DNS Provider" - }, - "certificates.dns.provider.placeholder": { - "defaultMessage": "Select a Provider..." - }, - "certificates.dns.warning": { - "defaultMessage": "This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective plugins documentation." - }, - "certificates.http.reachability-404": { - "defaultMessage": "There is a server found at this domain but it does not seem to be Nginx Proxy Manager. Please make sure your domain points to the IP where your NPM instance is running." - }, - "certificates.http.reachability-failed-to-check": { - "defaultMessage": "Failed to check the reachability due to a communication error with site24x7.com." - }, - "certificates.http.reachability-not-resolved": { - "defaultMessage": "There is no server available at this domain. Please make sure your domain exists and points to the IP where your NPM instance is running and if necessary port 80 is forwarded in your router." - }, - "certificates.http.reachability-ok": { - "defaultMessage": "Your server is reachable and creating certificates should be possible." - }, - "certificates.http.reachability-other": { - "defaultMessage": "There is a server found at this domain but it returned an unexpected status code {code}. Is it the NPM server? Please make sure your domain points to the IP where your NPM instance is running." - }, - "certificates.http.reachability-wrong-data": { - "defaultMessage": "There is a server found at this domain but it returned an unexpected data. Is it the NPM server? Please make sure your domain points to the IP where your NPM instance is running." - }, - "certificates.http.test-results": { - "defaultMessage": "Test Results" - }, - "certificates.http.warning": { - "defaultMessage": "These domains must be already configured to point to this installation." - }, - "certificates.key-type": { - "defaultMessage": "Key Type" - }, - "certificates.key-type-description": { - "defaultMessage": "RSA is widely compatible, ECDSA is faster and more secure but may not be supported by older systems" - }, - "certificates.key-type-ecdsa": { - "defaultMessage": "ECDSA 256" - }, - "certificates.key-type-rsa": { - "defaultMessage": "RSA 2048" - }, - "certificates.request.subtitle": { - "defaultMessage": "with Let's Encrypt" - }, - "certificates.request.title": { - "defaultMessage": "Request a new Certificate" - }, - "column.access": { - "defaultMessage": "Access" - }, - "column.authorization": { - "defaultMessage": "Authorization" - }, - "column.authorizations": { - "defaultMessage": "Authorizations" - }, - "column.custom-locations": { - "defaultMessage": "Custom Locations" - }, - "column.destination": { - "defaultMessage": "Destination" - }, - "column.details": { - "defaultMessage": "Details" - }, - "column.email": { - "defaultMessage": "Email" - }, - "column.error": { - "defaultMessage": "Error" - }, - "column.event": { - "defaultMessage": "Event" - }, - "column.expires": { - "defaultMessage": "Expires" - }, - "column.http-code": { - "defaultMessage": "HTTP Code" - }, - "column.incoming-port": { - "defaultMessage": "Incoming Port" - }, - "column.name": { - "defaultMessage": "Name" - }, - "column.protocol": { - "defaultMessage": "Protocol" - }, - "column.provider": { - "defaultMessage": "Provider" - }, - "column.roles": { - "defaultMessage": "Roles" - }, - "column.rules": { - "defaultMessage": "Rules" - }, - "column.satisfy": { - "defaultMessage": "Satisfy" - }, - "column.satisfy-all": { - "defaultMessage": "All" - }, - "column.satisfy-any": { - "defaultMessage": "Any" - }, - "column.scheme": { - "defaultMessage": "Scheme" - }, - "column.source": { - "defaultMessage": "Source" - }, - "column.ssl": { - "defaultMessage": "SSL" - }, - "column.status": { - "defaultMessage": "Status" - }, - "created-on": { - "defaultMessage": "Created: {date}" - }, - "dashboard": { - "defaultMessage": "Dashboard" - }, - "dead-host": { - "defaultMessage": "404 Host" - }, - "dead-hosts": { - "defaultMessage": "404 Hosts" - }, - "dead-hosts.count": { - "defaultMessage": "{count} {count, plural, one {404 Host} other {404 Hosts}}" - }, - "disabled": { - "defaultMessage": "Disabled" - }, - "domain-names": { - "defaultMessage": "Domain Names" - }, - "domain-names.max": { - "defaultMessage": "{count} domain names maximum" - }, - "domain-names.placeholder": { - "defaultMessage": "Start typing to add domain..." - }, - "domain-names.wildcards-not-permitted": { - "defaultMessage": "Wildcards not permitted for this type" - }, - "domain-names.wildcards-not-supported": { - "defaultMessage": "Wildcards not supported for this CA" - }, - "domains.advanced": { - "defaultMessage": "Advanced" - }, - "domains.force-ssl": { - "defaultMessage": "Force SSL" - }, - "domains.hsts-enabled": { - "defaultMessage": "HSTS Enabled" - }, - "domains.hsts-subdomains": { - "defaultMessage": "HSTS Sub-domains" - }, - "domains.http2-support": { - "defaultMessage": "HTTP/2 Support" - }, - "domains.trust-forwarded-proto": { - "defaultMessage": "Trust Upstream Forwarded Proto Headers" - }, - "domains.use-dns": { - "defaultMessage": "Use DNS Challenge" - }, - "email-address": { - "defaultMessage": "Email address" - }, - "empty-search": { - "defaultMessage": "No results found" - }, - "empty-subtitle": { - "defaultMessage": "Why don't you create one?" - }, - "enabled": { - "defaultMessage": "Enabled" - }, - "error.access.at-least-one": { - "defaultMessage": "Either one Authorization or one Access Rule is required" - }, - "error.access.duplicate-usernames": { - "defaultMessage": "Authorization Usernames must be unique" - }, - "error.invalid-auth": { - "defaultMessage": "Invalid email or password" - }, - "error.invalid-domain": { - "defaultMessage": "Invalid domain: {domain}" - }, - "error.invalid-email": { - "defaultMessage": "Invalid email address" - }, - "error.max-character-length": { - "defaultMessage": "Maximum length is {max} character{max, plural, one {} other {s}}" - }, - "error.max-domains": { - "defaultMessage": "Too many domains, max is {max}" - }, - "error.maximum": { - "defaultMessage": "Maximum is {max}" - }, - "error.min-character-length": { - "defaultMessage": "Minimum length is {min} character{min, plural, one {} other {s}}" - }, - "error.minimum": { - "defaultMessage": "Minimum is {min}" - }, - "error.passwords-must-match": { - "defaultMessage": "Passwords must match" - }, - "error.required": { - "defaultMessage": "This is required" - }, - "expires.on": { - "defaultMessage": "Expires: {date}" - }, - "footer.github-fork": { - "defaultMessage": "Fork me on Github" - }, - "host.flags.block-exploits": { - "defaultMessage": "Block Common Exploits" - }, - "host.flags.cache-assets": { - "defaultMessage": "Cache Assets" - }, - "host.flags.preserve-path": { - "defaultMessage": "Preserve Path" - }, - "host.flags.protocols": { - "defaultMessage": "Protocols" - }, - "host.flags.websockets-upgrade": { - "defaultMessage": "Websockets Support" - }, - "host.forward-port": { - "defaultMessage": "Forward Port" - }, - "host.forward-scheme": { - "defaultMessage": "Scheme" - }, - "hosts": { - "defaultMessage": "Hosts" - }, - "http-only": { - "defaultMessage": "HTTP Only" - }, - "lets-encrypt": { - "defaultMessage": "Let's Encrypt" - }, - "lets-encrypt-via-dns": { - "defaultMessage": "Let's Encrypt via DNS" - }, - "lets-encrypt-via-http": { - "defaultMessage": "Let's Encrypt via HTTP" - }, - "loading": { - "defaultMessage": "Loading…" - }, - "location.advanced-config": { - "defaultMessage": "Has custom Nginx configuration" - }, - "location.filter": { - "defaultMessage": "Filter by path or destination" - }, - "login.2fa-code": { - "defaultMessage": "Verification Code" - }, - "login.2fa-code-placeholder": { - "defaultMessage": "Enter code" - }, - "login.2fa-description": { - "defaultMessage": "Enter the code from your authenticator app" - }, - "login.2fa-title": { - "defaultMessage": "Two-Factor Authentication" - }, - "login.2fa-verify": { - "defaultMessage": "Verify" - }, - "login.title": { - "defaultMessage": "Login to your account" - }, - "logs": { - "defaultMessage": "Logs" - }, - "logs.channel.access": { - "defaultMessage": "Access" - }, - "logs.channel.error": { - "defaultMessage": "Error" - }, - "logs.download": { - "defaultMessage": "Download" - }, - "logs.empty": { - "defaultMessage": "No log entries yet." - }, - "logs.level": { - "defaultMessage": "Level" - }, - "logs.level.all": { - "defaultMessage": "All Levels" - }, - "logs.lines": { - "defaultMessage": "Lines" - }, - "logs.live": { - "defaultMessage": "Live" - }, - "logs.paused": { - "defaultMessage": "Paused" - }, - "logs.refresh": { - "defaultMessage": "Refresh" - }, - "logs.search-placeholder": { - "defaultMessage": "Search log…" - }, - "logs.source": { - "defaultMessage": "Source" - }, - "logs.source.system": { - "defaultMessage": "System" - }, - "logs.truncated-warning": { - "defaultMessage": "Only the most recent portion of this file is shown." - }, - "nginx-config.label": { - "defaultMessage": "Custom Nginx Configuration" - }, - "nginx-config.placeholder": { - "defaultMessage": "# Enter your custom Nginx configuration here at your own risk!" - }, - "no-permission-error": { - "defaultMessage": "You do not have access to view this." - }, - "notfound.action": { - "defaultMessage": "Take me home" - }, - "notfound.content": { - "defaultMessage": "We are sorry but the page you are looking for was not found" - }, - "notfound.title": { - "defaultMessage": "Oops… You just found an error page" - }, - "notification.error": { - "defaultMessage": "Error" - }, - "notification.object-deleted": { - "defaultMessage": "{object} has been deleted" - }, - "notification.object-disabled": { - "defaultMessage": "{object} has been disabled" - }, - "notification.object-enabled": { - "defaultMessage": "{object} has been enabled" - }, - "notification.object-renewed": { - "defaultMessage": "{object} has been renewed" - }, - "notification.object-saved": { - "defaultMessage": "{object} has been saved" - }, - "notification.success": { - "defaultMessage": "Success" - }, - "object.actions-title": { - "defaultMessage": "{object} #{id}" - }, - "object.add": { - "defaultMessage": "Add {object}" - }, - "object.delete": { - "defaultMessage": "Delete {object}" - }, - "object.delete.content": { - "defaultMessage": "Are you sure you want to delete this {object}?" - }, - "object.edit": { - "defaultMessage": "Edit {object}" - }, - "object.empty": { - "defaultMessage": "There are no {objects}" - }, - "object.event.created": { - "defaultMessage": "Created {object}" - }, - "object.event.deleted": { - "defaultMessage": "Deleted {object}" - }, - "object.event.disabled": { - "defaultMessage": "Disabled {object}" - }, - "object.event.enabled": { - "defaultMessage": "Enabled {object}" - }, - "object.event.renewed": { - "defaultMessage": "Renewed {object}" - }, - "object.event.updated": { - "defaultMessage": "Updated {object}" - }, - "offline": { - "defaultMessage": "Offline" - }, - "online": { - "defaultMessage": "Online" - }, - "options": { - "defaultMessage": "Options" - }, - "password": { - "defaultMessage": "Password" - }, - "password.generate": { - "defaultMessage": "Generate random password" - }, - "password.hide": { - "defaultMessage": "Hide Password" - }, - "password.show": { - "defaultMessage": "Show Password" - }, - "permissions.hidden": { - "defaultMessage": "Hidden" - }, - "permissions.manage": { - "defaultMessage": "Manage" - }, - "permissions.view": { - "defaultMessage": "View Only" - }, - "permissions.visibility.all": { - "defaultMessage": "All Items" - }, - "permissions.visibility.title": { - "defaultMessage": "Item Visibility" - }, - "permissions.visibility.user": { - "defaultMessage": "Created Items Only" - }, - "proxy-host": { - "defaultMessage": "Proxy Host" - }, - "proxy-host.forward-host": { - "defaultMessage": "Forward Hostname / IP" - }, - "proxy-hosts": { - "defaultMessage": "Proxy Hosts" - }, - "proxy-hosts.count": { - "defaultMessage": "{count} {count, plural, one {Proxy Host} other {Proxy Hosts}}" - }, - "public": { - "defaultMessage": "Public" - }, - "redirection-host": { - "defaultMessage": "Redirection Host" - }, - "redirection-host.forward-domain": { - "defaultMessage": "Forward Domain" - }, - "redirection-host.forward-http-code": { - "defaultMessage": "HTTP Code" - }, - "redirection-hosts": { - "defaultMessage": "Redirection Hosts" - }, - "redirection-hosts.count": { - "defaultMessage": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}" - }, - "redirection-hosts.http-code.300": { - "defaultMessage": "300 Multiple Choices" - }, - "redirection-hosts.http-code.301": { - "defaultMessage": "301 Moved permanently" - }, - "redirection-hosts.http-code.302": { - "defaultMessage": "302 Moved temporarily" - }, - "redirection-hosts.http-code.303": { - "defaultMessage": "303 See other" - }, - "redirection-hosts.http-code.307": { - "defaultMessage": "307 Temporary redirect" - }, - "redirection-hosts.http-code.308": { - "defaultMessage": "308 Permanent redirect" - }, - "role.admin": { - "defaultMessage": "Administrator" - }, - "role.standard-user": { - "defaultMessage": "Standard User" - }, - "save": { - "defaultMessage": "Save" - }, - "setting": { - "defaultMessage": "Setting" - }, - "settings": { - "defaultMessage": "Settings" - }, - "settings.default-site": { - "defaultMessage": "Default Site" - }, - "settings.default-site.404": { - "defaultMessage": "404 Page" - }, - "settings.default-site.444": { - "defaultMessage": "No Response (444)" - }, - "settings.default-site.congratulations": { - "defaultMessage": "Congratulations Page" - }, - "settings.default-site.description": { - "defaultMessage": "What to show when Nginx is hit with an unknown Host" - }, - "settings.default-site.html": { - "defaultMessage": "Custom HTML" - }, - "settings.default-site.html.placeholder": { - "defaultMessage": "" - }, - "settings.default-site.redirect": { - "defaultMessage": "Redirect" - }, - "setup.preamble": { - "defaultMessage": "Get started by creating your admin account." - }, - "setup.title": { - "defaultMessage": "Welcome!" - }, - "sign-in": { - "defaultMessage": "Sign in" - }, - "ssl-certificate": { - "defaultMessage": "SSL Certificate" - }, - "stream": { - "defaultMessage": "Stream" - }, - "stream.forward-host": { - "defaultMessage": "Forward Host" - }, - "stream.forward-host.placeholder": { - "defaultMessage": "example.com or 10.0.0.1 or 2001:db8:3333:4444:5555:6666:7777:8888" - }, - "stream.incoming-port": { - "defaultMessage": "Incoming Port" - }, - "streams": { - "defaultMessage": "Streams" - }, - "streams.count": { - "defaultMessage": "{count} {count, plural, one {Stream} other {Streams}}" - }, - "streams.tcp": { - "defaultMessage": "TCP" - }, - "streams.udp": { - "defaultMessage": "UDP" - }, - "test": { - "defaultMessage": "Test" - }, - "update-available": { - "defaultMessage": "Update Available: {latestVersion}" - }, - "user": { - "defaultMessage": "User" - }, - "user.change-password": { - "defaultMessage": "Change Password" - }, - "user.confirm-password": { - "defaultMessage": "Confirm Password" - }, - "user.current-password": { - "defaultMessage": "Current Password" - }, - "user.edit-profile": { - "defaultMessage": "Edit Profile" - }, - "user.full-name": { - "defaultMessage": "Full Name" - }, - "user.login-as": { - "defaultMessage": "Sign in as {name}" - }, - "user.logout": { - "defaultMessage": "Logout" - }, - "user.new-password": { - "defaultMessage": "New Password" - }, - "user.nickname": { - "defaultMessage": "Nickname" - }, - "user.set-password": { - "defaultMessage": "Set Password" - }, - "user.set-permissions": { - "defaultMessage": "Set Permissions for {name}" - }, - "user.switch-dark": { - "defaultMessage": "Switch to Dark mode" - }, - "user.switch-light": { - "defaultMessage": "Switch to Light mode" - }, - "user.two-factor": { - "defaultMessage": "Two-Factor Auth" - }, - "username": { - "defaultMessage": "Username" - }, - "users": { - "defaultMessage": "Users" - } -} diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json deleted file mode 100644 index 316ede752e..0000000000 --- a/frontend/src/locale/src/et.json +++ /dev/null @@ -1,842 +0,0 @@ -{ - "2fa.backup-codes-remaining": { - "defaultMessage": "Varukoode jäänud: {count}" - }, - "2fa.backup-warning": { - "defaultMessage": "Pane need varukoodid kindlasse kohta tallele. Iga koodi saab kasutada ainult üks kord." - }, - "2fa.disable": { - "defaultMessage": "Lülita kaheastmeline autentimine välja" - }, - "2fa.disable-confirm": { - "defaultMessage": "Lülita 2FA välja" - }, - "2fa.disable-warning": { - "defaultMessage": "Ilma kaheastmelise autentimiseta on konto kergemini rünnatav." - }, - "2fa.disabled": { - "defaultMessage": "Väljas" - }, - "2fa.done": { - "defaultMessage": "Varukoodid on tallele pandud" - }, - "2fa.enable": { - "defaultMessage": "Lülita kaheastmeline autentimine sisse" - }, - "2fa.enabled": { - "defaultMessage": "Sees" - }, - "2fa.enter-code": { - "defaultMessage": "Sisesta kinnituskood" - }, - "2fa.enter-code-disable": { - "defaultMessage": "Sisesta kinnituskood, et välja lülitada" - }, - "2fa.regenerate": { - "defaultMessage": "Loo uuesti" - }, - "2fa.regenerate-backup": { - "defaultMessage": "Loo uued varukoodid" - }, - "2fa.regenerate-instructions": { - "defaultMessage": "Sisesta kinnituskood, et luua uued varukoodid. Vanad koodid muutuvad kehtetuks." - }, - "2fa.secret-key": { - "defaultMessage": "Salavõti" - }, - "2fa.setup-instructions": { - "defaultMessage": "Skanni QR-kood autentimisäpiga või sisesta salavõti käsitsi." - }, - "2fa.status": { - "defaultMessage": "Olek" - }, - "2fa.title": { - "defaultMessage": "Kaheastmeline autentimine" - }, - "2fa.verify-enable": { - "defaultMessage": "Kinnita ja lülita sisse" - }, - "access-list": { - "defaultMessage": "Juurdepääsuloend" - }, - "access-list.access-count": { - "defaultMessage": "{count} {count, plural, one {reegel} other {reeglit}}" - }, - "access-list.auth-count": { - "defaultMessage": "{count} {count, plural, one {kasutaja} other {kasutajat}}" - }, - "access-list.help-rules-last": { - "defaultMessage": "Kui vähemalt üks reegel on olemas, lisatakse lõppu keeld kõigile ülejäänutele" - }, - "access-list.help.rules-order": { - "defaultMessage": "Luba ja keela reeglid rakenduvad selles järjekorras, milles need on kirjas." - }, - "access-list.inherit": { - "defaultMessage": "Päri puhverserverilt" - }, - "access-list.inherit.subtitle": { - "defaultMessage": "Kasutab puhverserveri juurdepääsuloendit" - }, - "access-list.pass-auth": { - "defaultMessage": "Edasta autentimine sihtserverile" - }, - "access-list.public": { - "defaultMessage": "Avalikult ligipääsetav" - }, - "access-list.public.subtitle": { - "defaultMessage": "Basic-autentimist ei küsita" - }, - "access-list.rule-source.placeholder": { - "defaultMessage": "192.168.1.100 või 192.168.1.0/24 või 2001:0db8::/32" - }, - "access-list.satisfy-any": { - "defaultMessage": "Piisab ühest tingimusest" - }, - "access-list.subtitle": { - "defaultMessage": "{users} {users, plural, one {kasutaja} other {kasutajat}}, {rules} {rules, plural, one {reegel} other {reeglit}} – loodud: {date}" - }, - "access-lists": { - "defaultMessage": "Juurdepääsuloendid" - }, - "action.add": { - "defaultMessage": "Lisa" - }, - "action.add-location": { - "defaultMessage": "Lisa asukoht" - }, - "action.allow": { - "defaultMessage": "Luba" - }, - "action.clear": { - "defaultMessage": "Tühjenda" - }, - "action.close": { - "defaultMessage": "Sulge" - }, - "action.delete": { - "defaultMessage": "Kustuta" - }, - "action.deny": { - "defaultMessage": "Keela" - }, - "action.disable": { - "defaultMessage": "Lülita välja" - }, - "action.download": { - "defaultMessage": "Laadi alla" - }, - "action.edit": { - "defaultMessage": "Muuda" - }, - "action.enable": { - "defaultMessage": "Lülita sisse" - }, - "action.logs": { - "defaultMessage": "Logid" - }, - "action.permissions": { - "defaultMessage": "Õigused" - }, - "action.renew": { - "defaultMessage": "Uuenda" - }, - "action.view-details": { - "defaultMessage": "Vaata üksikasju" - }, - "auditlogs": { - "defaultMessage": "Auditilogid" - }, - "auto": { - "defaultMessage": "Automaatne" - }, - "cancel": { - "defaultMessage": "Tühista" - }, - "certificate": { - "defaultMessage": "Sertifikaat" - }, - "certificate.custom-certificate": { - "defaultMessage": "Sertifikaat" - }, - "certificate.custom-certificate-key": { - "defaultMessage": "Sertifikaadi võti" - }, - "certificate.custom-intermediate": { - "defaultMessage": "Vahesertifikaat" - }, - "certificate.in-use": { - "defaultMessage": "Kasutusel" - }, - "certificate.none.subtitle": { - "defaultMessage": "Sertifikaati pole määratud" - }, - "certificate.none.subtitle.for-http": { - "defaultMessage": "See host ei kasuta HTTPS-i" - }, - "certificate.none.title": { - "defaultMessage": "Puudub" - }, - "certificate.not-in-use": { - "defaultMessage": "Ei ole kasutusel" - }, - "certificate.renew": { - "defaultMessage": "Uuenda sertifikaati" - }, - "certificates": { - "defaultMessage": "Sertifikaadid" - }, - "certificates.custom": { - "defaultMessage": "Kohandatud sertifikaat" - }, - "certificates.custom.warning": { - "defaultMessage": "Parooliga kaitstud võtmefaile ei toetata." - }, - "certificates.dns.credentials": { - "defaultMessage": "Tunnuste faili sisu" - }, - "certificates.dns.credentials-note": { - "defaultMessage": "See plugin tahab seadistusfaili, kus on teenusepakkuja API token või muud tunnused" - }, - "certificates.dns.credentials-warning": { - "defaultMessage": "Need andmed jäävad andmebaasi ja faili avatekstina!" - }, - "certificates.dns.propagation-seconds": { - "defaultMessage": "DNS-i leviku ooteaeg" - }, - "certificates.dns.propagation-seconds-note": { - "defaultMessage": "Jäta tühjaks, kui tahad plugina vaikeväärtust. Mitu sekundit DNS-i levikut oodata." - }, - "certificates.dns.provider": { - "defaultMessage": "DNS-teenuse pakkuja" - }, - "certificates.dns.provider.placeholder": { - "defaultMessage": "Vali pakkuja..." - }, - "certificates.dns.warning": { - "defaultMessage": "See osa eeldab Certboti ja selle DNS-pluginate tundmist. Vaata vastava plugina juhendit." - }, - "certificates.http.reachability-404": { - "defaultMessage": "Sellel domeenil on küll server, aga see ei tundu olevat Nginx Proxy Manager. Vaata, et domeen osutaks IP-le, kus NPM tegelikult töötab." - }, - "certificates.http.reachability-failed-to-check": { - "defaultMessage": "Kättesaadavust ei saanud kontrollida, sest side site24x7.com-iga katkes." - }, - "certificates.http.reachability-not-resolved": { - "defaultMessage": "Sellel domeenil ei ole serverit. Vaata, et domeen olemas oleks, osutaks NPM-i IP-le ja vajadusel oleks ruuteris avatud port 80." - }, - "certificates.http.reachability-ok": { - "defaultMessage": "Server on kättesaadav, sertifikaadi saab ära teha." - }, - "certificates.http.reachability-other": { - "defaultMessage": "Sellel domeenil on server, aga vastus tuli ootamatu koodiga {code}. Kas see on NPM? Vaata, et domeen osutaks IP-le, kus NPM töötab." - }, - "certificates.http.reachability-wrong-data": { - "defaultMessage": "Sellel domeenil on server, aga vastuse sisu ei klapi. Kas see on NPM? Vaata, et domeen osutaks IP-le, kus NPM töötab." - }, - "certificates.http.test-results": { - "defaultMessage": "Testi tulemused" - }, - "certificates.http.warning": { - "defaultMessage": "Need domeenid peavad juba osutama sellele paigaldusele." - }, - "certificates.key-type": { - "defaultMessage": "Võtme tüüp" - }, - "certificates.key-type-description": { - "defaultMessage": "RSA klapib peaaegu kõikjale, ECDSA on kiirem ja turvalisem, aga vanemad süsteemid ei pruugi seda tunda" - }, - "certificates.key-type-ecdsa": { - "defaultMessage": "ECDSA 256" - }, - "certificates.key-type-rsa": { - "defaultMessage": "RSA 2048" - }, - "certificates.request.subtitle": { - "defaultMessage": "Let's Encryptiga" - }, - "certificates.request.title": { - "defaultMessage": "Taotle uut sertifikaati" - }, - "column.access": { - "defaultMessage": "Juurdepääs" - }, - "column.authorization": { - "defaultMessage": "Autentimine" - }, - "column.authorizations": { - "defaultMessage": "Autentimised" - }, - "column.custom-locations": { - "defaultMessage": "Kohandatud asukohad" - }, - "column.destination": { - "defaultMessage": "Sihtkoht" - }, - "column.details": { - "defaultMessage": "Üksikasjad" - }, - "column.email": { - "defaultMessage": "E-post" - }, - "column.error": { - "defaultMessage": "Viga" - }, - "column.event": { - "defaultMessage": "Sündmus" - }, - "column.expires": { - "defaultMessage": "Aegub" - }, - "column.http-code": { - "defaultMessage": "HTTP-kood" - }, - "column.incoming-port": { - "defaultMessage": "Sisendport" - }, - "column.name": { - "defaultMessage": "Nimi" - }, - "column.protocol": { - "defaultMessage": "Protokoll" - }, - "column.provider": { - "defaultMessage": "Pakkuja" - }, - "column.roles": { - "defaultMessage": "Rollid" - }, - "column.rules": { - "defaultMessage": "Reeglid" - }, - "column.satisfy": { - "defaultMessage": "Tingimus" - }, - "column.satisfy-all": { - "defaultMessage": "Kõik" - }, - "column.satisfy-any": { - "defaultMessage": "Mõni" - }, - "column.scheme": { - "defaultMessage": "Skeem" - }, - "column.source": { - "defaultMessage": "Allikas" - }, - "column.ssl": { - "defaultMessage": "SSL" - }, - "column.status": { - "defaultMessage": "Olek" - }, - "created-on": { - "defaultMessage": "Loodud: {date}" - }, - "dashboard": { - "defaultMessage": "Töölaud" - }, - "dead-host": { - "defaultMessage": "404-host" - }, - "dead-hosts": { - "defaultMessage": "404-hostid" - }, - "dead-hosts.count": { - "defaultMessage": "{count} {count, plural, one {404-host} other {404-hosti}}" - }, - "disabled": { - "defaultMessage": "Väljas" - }, - "domain-names": { - "defaultMessage": "Domeeninimed" - }, - "domain-names.max": { - "defaultMessage": "Kuni {count} domeeninime" - }, - "domain-names.placeholder": { - "defaultMessage": "Kirjuta, et domeen lisada..." - }, - "domain-names.wildcards-not-permitted": { - "defaultMessage": "Selle tüübi puhul metamärke ei saa" - }, - "domain-names.wildcards-not-supported": { - "defaultMessage": "See CA metamärke ei toeta" - }, - "domains.advanced": { - "defaultMessage": "Lisavalikud" - }, - "domains.force-ssl": { - "defaultMessage": "Sunni SSL" - }, - "domains.hsts-enabled": { - "defaultMessage": "HSTS sees" - }, - "domains.hsts-subdomains": { - "defaultMessage": "HSTS alamdomeenidel" - }, - "domains.http2-support": { - "defaultMessage": "HTTP/2 tugi" - }, - "domains.trust-forwarded-proto": { - "defaultMessage": "Usalda sihtserveri Forwarded Proto päiseid" - }, - "domains.use-dns": { - "defaultMessage": "Kasuta DNS-väljakutset" - }, - "email-address": { - "defaultMessage": "E-posti aadress" - }, - "empty-search": { - "defaultMessage": "Tulemusi ei leitud" - }, - "empty-subtitle": { - "defaultMessage": "Miks mitte üks luua?" - }, - "enabled": { - "defaultMessage": "Sees" - }, - "error.access.at-least-one": { - "defaultMessage": "Vaja on vähemalt ühte kasutajat või juurdepääsureeglit" - }, - "error.access.duplicate-usernames": { - "defaultMessage": "Kasutajanimed peavad olema unikaalsed" - }, - "error.invalid-auth": { - "defaultMessage": "Vale e-post või parool" - }, - "error.invalid-domain": { - "defaultMessage": "Vigane domeen: {domain}" - }, - "error.invalid-email": { - "defaultMessage": "Vigane e-posti aadress" - }, - "error.max-character-length": { - "defaultMessage": "Maksimumpikkus on {max} {max, plural, one {tähemärk} other {tähemärki}}" - }, - "error.max-domains": { - "defaultMessage": "Liiga palju domeene, maksimum on {max}" - }, - "error.maximum": { - "defaultMessage": "Maksimum on {max}" - }, - "error.min-character-length": { - "defaultMessage": "Miinimumpikkus on {min} {min, plural, one {tähemärk} other {tähemärki}}" - }, - "error.minimum": { - "defaultMessage": "Miinimum on {min}" - }, - "error.passwords-must-match": { - "defaultMessage": "Paroolid peavad klappima" - }, - "error.required": { - "defaultMessage": "See on kohustuslik" - }, - "expires.on": { - "defaultMessage": "Aegub: {date}" - }, - "footer.github-fork": { - "defaultMessage": "Tee GitHubis fork" - }, - "host.flags.block-exploits": { - "defaultMessage": "Blokeeri levinud ründed" - }, - "host.flags.cache-assets": { - "defaultMessage": "Puhverda failid" - }, - "host.flags.preserve-path": { - "defaultMessage": "Säilita tee" - }, - "host.flags.protocols": { - "defaultMessage": "Protokollid" - }, - "host.flags.websockets-upgrade": { - "defaultMessage": "Websocketi tugi" - }, - "host.forward-port": { - "defaultMessage": "Edastusport" - }, - "host.forward-scheme": { - "defaultMessage": "Skeem" - }, - "hosts": { - "defaultMessage": "Hostid" - }, - "http-only": { - "defaultMessage": "Ainult HTTP" - }, - "lets-encrypt": { - "defaultMessage": "Let's Encrypt" - }, - "lets-encrypt-via-dns": { - "defaultMessage": "Let's Encrypt DNS-iga" - }, - "lets-encrypt-via-http": { - "defaultMessage": "Let's Encrypt HTTP-ga" - }, - "loading": { - "defaultMessage": "Laadimine…" - }, - "location.advanced-config": { - "defaultMessage": "Kohandatud Nginx seadistus olemas" - }, - "location.filter": { - "defaultMessage": "Filtreeri tee või sihtkoha järgi" - }, - "login.2fa-code": { - "defaultMessage": "Kinnituskood" - }, - "login.2fa-code-placeholder": { - "defaultMessage": "Sisesta kood" - }, - "login.2fa-description": { - "defaultMessage": "Sisesta kood autentimisäpist" - }, - "login.2fa-title": { - "defaultMessage": "Kaheastmeline autentimine" - }, - "login.2fa-verify": { - "defaultMessage": "Kinnita" - }, - "login.title": { - "defaultMessage": "Logi kontole sisse" - }, - "logs": { - "defaultMessage": "Logid" - }, - "logs.channel.access": { - "defaultMessage": "Juurdepääs" - }, - "logs.channel.error": { - "defaultMessage": "Viga" - }, - "logs.download": { - "defaultMessage": "Laadi alla" - }, - "logs.empty": { - "defaultMessage": "Logikirjeid pole veel." - }, - "logs.level": { - "defaultMessage": "Tase" - }, - "logs.level.all": { - "defaultMessage": "Kõik tasemed" - }, - "logs.lines": { - "defaultMessage": "Read" - }, - "logs.live": { - "defaultMessage": "Reaalajas" - }, - "logs.paused": { - "defaultMessage": "Peatatud" - }, - "logs.refresh": { - "defaultMessage": "Värskenda" - }, - "logs.search-placeholder": { - "defaultMessage": "Otsi logist…" - }, - "logs.source": { - "defaultMessage": "Allikas" - }, - "logs.source.system": { - "defaultMessage": "Süsteem" - }, - "logs.truncated-warning": { - "defaultMessage": "Kuvatakse ainult faili kõige uuem osa." - }, - "nginx-config.label": { - "defaultMessage": "Kohandatud Nginx seadistus" - }, - "nginx-config.placeholder": { - "defaultMessage": "# Sisesta siia oma Nginx seadistus omal vastutusel!" - }, - "no-permission-error": { - "defaultMessage": "Selle vaatamiseks sul õigust pole." - }, - "notfound.action": { - "defaultMessage": "Tagasi avalehele" - }, - "notfound.content": { - "defaultMessage": "Otsitud lehte ei ole." - }, - "notfound.title": { - "defaultMessage": "Oih… lehte ei leitud" - }, - "notification.error": { - "defaultMessage": "Viga" - }, - "notification.object-deleted": { - "defaultMessage": "{object} on kustutatud" - }, - "notification.object-disabled": { - "defaultMessage": "{object} on välja lülitatud" - }, - "notification.object-enabled": { - "defaultMessage": "{object} on sisse lülitatud" - }, - "notification.object-renewed": { - "defaultMessage": "{object} on uuendatud" - }, - "notification.object-saved": { - "defaultMessage": "{object} on salvestatud" - }, - "notification.success": { - "defaultMessage": "Korras" - }, - "object.actions-title": { - "defaultMessage": "{object} #{id}" - }, - "object.add": { - "defaultMessage": "Lisa {object}" - }, - "object.delete": { - "defaultMessage": "Kustuta {object}" - }, - "object.delete.content": { - "defaultMessage": "Kas oled kindel, et tahad selle kustutada: {object}?" - }, - "object.edit": { - "defaultMessage": "Muuda {object}" - }, - "object.empty": { - "defaultMessage": "{objects} puuduvad" - }, - "object.event.created": { - "defaultMessage": "Loodud: {object}" - }, - "object.event.deleted": { - "defaultMessage": "Kustutatud: {object}" - }, - "object.event.disabled": { - "defaultMessage": "Välja lülitatud: {object}" - }, - "object.event.enabled": { - "defaultMessage": "Sisse lülitatud: {object}" - }, - "object.event.renewed": { - "defaultMessage": "Uuendatud: {object}" - }, - "object.event.updated": { - "defaultMessage": "Muudetud: {object}" - }, - "offline": { - "defaultMessage": "Maas" - }, - "online": { - "defaultMessage": "Töös" - }, - "options": { - "defaultMessage": "Valikud" - }, - "password": { - "defaultMessage": "Parool" - }, - "password.generate": { - "defaultMessage": "Loo juhuslik parool" - }, - "password.hide": { - "defaultMessage": "Peida parool" - }, - "password.show": { - "defaultMessage": "Näita parooli" - }, - "permissions.hidden": { - "defaultMessage": "Peidetud" - }, - "permissions.manage": { - "defaultMessage": "Halda" - }, - "permissions.view": { - "defaultMessage": "Ainult vaatamine" - }, - "permissions.visibility.all": { - "defaultMessage": "Kõik kirjed" - }, - "permissions.visibility.title": { - "defaultMessage": "Kirjete nähtavus" - }, - "permissions.visibility.user": { - "defaultMessage": "Ainult enda loodud" - }, - "proxy-host": { - "defaultMessage": "Puhverserver" - }, - "proxy-host.forward-host": { - "defaultMessage": "Edastuse hostinimi / IP" - }, - "proxy-hosts": { - "defaultMessage": "Puhverserverid" - }, - "proxy-hosts.count": { - "defaultMessage": "{count} {count, plural, one {puhverserver} other {puhverserverit}}" - }, - "public": { - "defaultMessage": "Avalik" - }, - "redirection-host": { - "defaultMessage": "Ümbersuunamishost" - }, - "redirection-host.forward-domain": { - "defaultMessage": "Sihtkoha domeen" - }, - "redirection-host.forward-http-code": { - "defaultMessage": "HTTP-kood" - }, - "redirection-hosts": { - "defaultMessage": "Ümbersuunamishostid" - }, - "redirection-hosts.count": { - "defaultMessage": "{count} {count, plural, one {ümbersuunamishost} other {ümbersuunamishosti}}" - }, - "redirection-hosts.http-code.300": { - "defaultMessage": "300 mitu valikut" - }, - "redirection-hosts.http-code.301": { - "defaultMessage": "301 jäädavalt teisaldatud" - }, - "redirection-hosts.http-code.302": { - "defaultMessage": "302 ajutiselt teisaldatud" - }, - "redirection-hosts.http-code.303": { - "defaultMessage": "303 vaata mujal" - }, - "redirection-hosts.http-code.307": { - "defaultMessage": "307 ajutine ümbersuunamine" - }, - "redirection-hosts.http-code.308": { - "defaultMessage": "308 jäädav ümbersuunamine" - }, - "role.admin": { - "defaultMessage": "Administraator" - }, - "role.standard-user": { - "defaultMessage": "Tavakasutaja" - }, - "save": { - "defaultMessage": "Salvesta" - }, - "setting": { - "defaultMessage": "Seade" - }, - "settings": { - "defaultMessage": "Seaded" - }, - "settings.default-site": { - "defaultMessage": "Vaikesait" - }, - "settings.default-site.404": { - "defaultMessage": "404 leht" - }, - "settings.default-site.444": { - "defaultMessage": "Vastuseta (444)" - }, - "settings.default-site.congratulations": { - "defaultMessage": "Õnnitlusleht" - }, - "settings.default-site.description": { - "defaultMessage": "Mida näidata, kui päring tuleb tundmatule hostile" - }, - "settings.default-site.html": { - "defaultMessage": "Kohandatud HTML" - }, - "settings.default-site.html.placeholder": { - "defaultMessage": "" - }, - "settings.default-site.redirect": { - "defaultMessage": "Ümbersuunamine" - }, - "setup.preamble": { - "defaultMessage": "Alustuseks loo endale administraatori konto." - }, - "setup.title": { - "defaultMessage": "Tere tulemast!" - }, - "sign-in": { - "defaultMessage": "Logi sisse" - }, - "ssl-certificate": { - "defaultMessage": "SSL-sertifikaat" - }, - "stream": { - "defaultMessage": "Voog" - }, - "stream.forward-host": { - "defaultMessage": "Edastuse host" - }, - "stream.forward-host.placeholder": { - "defaultMessage": "example.com või 10.0.0.1 või 2001:db8:3333:4444:5555:6666:7777:8888" - }, - "stream.incoming-port": { - "defaultMessage": "Sisendport" - }, - "streams": { - "defaultMessage": "Vood" - }, - "streams.count": { - "defaultMessage": "{count} {count, plural, one {voog} other {voogu}}" - }, - "streams.tcp": { - "defaultMessage": "TCP" - }, - "streams.udp": { - "defaultMessage": "UDP" - }, - "test": { - "defaultMessage": "Testi" - }, - "update-available": { - "defaultMessage": "Uuendus saadaval: {latestVersion}" - }, - "user": { - "defaultMessage": "Kasutaja" - }, - "user.change-password": { - "defaultMessage": "Muuda parooli" - }, - "user.confirm-password": { - "defaultMessage": "Kinnita parool" - }, - "user.current-password": { - "defaultMessage": "Praegune parool" - }, - "user.edit-profile": { - "defaultMessage": "Muuda profiili" - }, - "user.full-name": { - "defaultMessage": "Täisnimi" - }, - "user.login-as": { - "defaultMessage": "Logi sisse kasutajana {name}" - }, - "user.logout": { - "defaultMessage": "Logi välja" - }, - "user.new-password": { - "defaultMessage": "Uus parool" - }, - "user.nickname": { - "defaultMessage": "Hüüdnimi" - }, - "user.set-password": { - "defaultMessage": "Määra parool" - }, - "user.set-permissions": { - "defaultMessage": "Määra {name} õigused" - }, - "user.switch-dark": { - "defaultMessage": "Lülitu tumedale kujundusele" - }, - "user.switch-light": { - "defaultMessage": "Lülitu heledale kujundusele" - }, - "user.two-factor": { - "defaultMessage": "Kaheastmeline autentimine" - }, - "username": { - "defaultMessage": "Kasutajanimi" - }, - "users": { - "defaultMessage": "Kasutajad" - } -} diff --git a/frontend/src/modals/HostLogsModal.tsx b/frontend/src/modals/HostLogsModal.tsx deleted file mode 100644 index a0df04ee71..0000000000 --- a/frontend/src/modals/HostLogsModal.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import CodeEditor from "@uiw/react-textarea-code-editor"; -import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { useState } from "react"; -import { Alert } from "react-bootstrap"; -import Modal from "react-bootstrap/Modal"; -import { Button, Loading } from "src/components"; -import { useProxyHostLogs } from "src/hooks"; -import { T } from "src/locale"; - -const showHostLogsModal = (id: number) => { - EasyModal.show(HostLogsModal, { id }); -}; - -interface Props extends InnerModalProps { - id: number; -} -const HostLogsModal = EasyModal.create(({ id, visible, remove }: Props) => { - const [logType, setLogType] = useState<"access" | "error">("access"); - const { data, isLoading, error } = useProxyHostLogs(id, logType); - - return ( - - {!isLoading && error && ( - - {error?.message || "Unknown error"} - - )} - - - - - - -
- {isLoading ? ( - - ) : ( -
- -
- )} - - - - - - ); -}); - -export { showHostLogsModal }; diff --git a/frontend/src/modals/index.js b/frontend/src/modals/index.js index e0e39b50d0..ab13685da3 100644 --- a/frontend/src/modals/index.js +++ b/frontend/src/modals/index.js @@ -6,7 +6,6 @@ export * from "./DeleteConfirmModal"; export * from "./DNSCertificateModal"; export * from "./EventDetailsModal"; export * from "./HelpModal"; -export * from "./HostLogsModal"; export * from "./HTTPCertificateModal"; export * from "./MfaModal"; export * from "./PermissionsModal"; diff --git a/frontend/translations/ui/en.json b/frontend/translations/ui/en.json index d1d1f517d7..8ab1b917ab 100644 --- a/frontend/translations/ui/en.json +++ b/frontend/translations/ui/en.json @@ -9,6 +9,8 @@ "access-list.help-rules-last": "When at least 1 rule exists, this deny all rule will be added last", "access-list.help.rules-last": "This final rule will only be added if there is at least one other rule", "access-list.help.rules-order": "Note that the allow and deny directives will be applied in the order they are defined.", + "access-list.inherit": "Inherit from Proxy Host", + "access-list.inherit.subtitle": "Uses the proxy host's access list", "access-list.pass-auth": "Pass Auth to Upstream", "access-list.public": "Publicly Accessible", "access-list.public.subtitle": "No basic auth required", @@ -29,6 +31,7 @@ "action.download": "Download", "action.edit": "Edit", "action.enable": "Enable", + "action.logs": "Logs", "action.permissions": "Permissions", "action.renew": "Renew", "action.view-details": "View Details", @@ -81,6 +84,7 @@ "column.destination": "Destination", "column.details": "Details", "column.email": "Email", + "column.error": "Error", "column.event": "Event", "column.expires": "Expires", "column.http-code": "HTTP Code", diff --git a/frontend/translations/ui/et.json b/frontend/translations/ui/et.json index a8a50da5b8..e4ddbe2cb2 100644 --- a/frontend/translations/ui/et.json +++ b/frontend/translations/ui/et.json @@ -4,6 +4,8 @@ "access-list.auth-count": "{count} {count, plural, one {kasutaja} other {kasutajat}}", "access-list.help-rules-last": "Kui vähemalt üks reegel on olemas, lisatakse lõppu keeld kõigile ülejäänutele", "access-list.help.rules-order": "Luba ja keela reeglid rakenduvad selles järjekorras, milles need on kirjas.", + "access-list.inherit": "Päri puhverserverilt", + "access-list.inherit.subtitle": "Kasutab puhverserveri juurdepääsuloendit", "access-list.pass-auth": "Edasta autentimine sihtserverile", "access-list.public": "Avalikult ligipääsetav", "access-list.public.subtitle": "Basic-autentimist ei küsita", @@ -22,6 +24,7 @@ "action.download": "Laadi alla", "action.edit": "Muuda", "action.enable": "Lülita sisse", + "action.logs": "Logid", "action.permissions": "Õigused", "action.renew": "Uuenda", "action.view-details": "Vaata üksikasju", @@ -70,6 +73,7 @@ "column.destination": "Sihtkoht", "column.details": "Üksikasjad", "column.email": "E-post", + "column.error": "Viga", "column.event": "Sündmus", "column.expires": "Aegub", "column.http-code": "HTTP-kood", diff --git a/test/cypress/e2e/api/AccessListLocations.cy.js b/test/cypress/e2e/api/AccessListLocations.cy.js deleted file mode 100644 index 2ea2742ef8..0000000000 --- a/test/cypress/e2e/api/AccessListLocations.cy.js +++ /dev/null @@ -1,301 +0,0 @@ -/// - -describe('Per-path Access Lists', () => { - const domain = 'website3.example.com'; - - const alpha = { - name: 'Path Alpha', - username: 'alpha-user', - password: 'alpha-pass', - }; - - const beta = { - name: 'Path Beta', - username: 'beta-user', - password: 'beta-pass', - }; - - let token; - let alphaListId; - let betaListId; - let hostId; - - /** - * Requests a path on the proxy host directly (bypassing squid) - * and yields the HTTP status code and response body - * - * @param {string} path - * @param {object} [creds] - * @param {string} creds.username - * @param {string} creds.password - */ - const request = (path, creds) => { - const auth = creds ? `-u '${creds.username}:${creds.password}'` : ''; - return cy.exec(`curl --noproxy '*' -s -w '\n%{http_code}' ${auth} http://${domain}${path}`) - .then((result) => { - expect(result.exitCode).to.eq(0); - const lines = result.stdout.trim().split('\n'); - const status = lines.pop(); - return { status: status, body: lines.join('\n') }; - }); - }; - - // nginx reloads are signalled and return immediately, so the old - // config can still be served for a moment after an API change. - // Retry until the expected response is seen. - const waitForResponse = (path, creds, check, description) => { - // Copy now, the callback runs later and creds may have been changed by then - const credsCopy = creds ? { ...creds } : null; - let last = null; - cy.waitUntil(() => request(path, credsCopy).then((res) => { - last = res; - return check(res); - }), { - timeout: 15000, - interval: 500, - errorMsg: () => `${path} did not return ${description}, last status: ${last?.status}`, - }); - }; - - const expectStatus = (path, creds, status) => { - waitForResponse(path, creds, (res) => res.status === status, status); - }; - - // Also checks the body so we know the page came from examplesite - // and not the NPM default site - const expectPage = (path, creds, text) => { - waitForResponse(path, creds, (res) => res.status === '200' && res.body.includes(text), `200 with "${text}"`); - }; - - const createAccessList = (list) => { - return cy.task('backendApiPost', { - token: token, - path: '/api/nginx/access-lists', - data: { - name: list.name, - satisfy_any: false, - pass_auth: false, - items: [ - { - username: list.username, - password: list.password, - } - ], - clients: [], - } - }).then((data) => { - cy.validateSwaggerSchema('post', 201, '/nginx/access-lists', data); - expect(data).to.have.property('id'); - expect(data.id).to.be.greaterThan(0); - return cy.wrap(data.id); - }); - }; - - const location = (path, accessListId) => { - return { - path: path, - forward_scheme: 'http', - forward_host: 'examplesite', - forward_port: 80, - access_list_id: accessListId, - }; - }; - - before(() => { - cy.resetUsers(); - cy.getToken().then((tok) => { - token = tok; - }); - }); - - it('Should be able to create multiple access lists with credentials', () => { - createAccessList(alpha).then((id) => { - alphaListId = id; - }); - createAccessList(beta).then((id) => { - betaListId = id; - expect(betaListId).to.not.equal(alphaListId); - }); - }); - - it('Should be able to create a proxy host with a different access list per location', () => { - cy.task('backendApiPost', { - token: token, - path: '/api/nginx/proxy-hosts', - data: { - domain_names: [domain], - forward_scheme: 'http', - forward_host: 'examplesite', - forward_port: 80, - access_list_id: 0, - certificate_id: 0, - meta: { - dns_challenge: false - }, - advanced_config: '', - locations: [ - location('/dashboard', alphaListId), - location('/profile', betaListId), - ], - block_exploits: false, - caching_enabled: false, - allow_websocket_upgrade: false, - http2_support: false, - hsts_enabled: false, - hsts_subdomains: false, - ssl_forced: false - } - }).then((data) => { - cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data); - expect(data).to.have.property('id'); - expect(data.id).to.be.greaterThan(0); - hostId = data.id; - expect(data).to.have.property('enabled', true); - expect(data).to.have.property('access_list_id', 0); - expect(data.locations).to.have.length(2); - expect(data.locations[0]).to.have.property('access_list_id', alphaListId); - expect(data.locations[1]).to.have.property('access_list_id', betaListId); - }); - }); - - it('Should persist the location access lists on the proxy host', () => { - cy.task('backendApiGet', { - token: token, - path: `/api/nginx/proxy-hosts/${hostId}`, - }).then((data) => { - cy.validateSwaggerSchema('get', 200, '/nginx/proxy-hosts/{hostID}', data); - expect(data.locations[0]).to.have.property('path', '/dashboard'); - expect(data.locations[0]).to.have.property('access_list_id', alphaListId); - expect(data.locations[1]).to.have.property('path', '/profile'); - expect(data.locations[1]).to.have.property('access_list_id', betaListId); - }); - }); - - it('Should not require auth for the host root', () => { - expectPage('/', null, 'this is the index page'); - }); - - it('Should only accept the alpha credentials on /dashboard', () => { - expectStatus('/dashboard', null, '401'); - expectStatus('/dashboard', beta, '401'); - expectPage('/dashboard', alpha, 'this is the dashboard page'); - }); - - it('Should only accept the beta credentials on /profile', () => { - expectStatus('/profile', null, '401'); - expectStatus('/profile', alpha, '401'); - expectPage('/profile', beta, 'this is the profile page'); - }); - - it('Should apply access list credential changes to locations using it', () => { - const newPassword = 'alpha-pass-changed'; - - cy.task('backendApiPut', { - token: token, - path: `/api/nginx/access-lists/${alphaListId}`, - data: { - name: alpha.name, - satisfy_any: false, - pass_auth: false, - items: [ - { - username: alpha.username, - password: newPassword, - } - ], - clients: [], - } - }).then((data) => { - cy.validateSwaggerSchema('put', 200, '/nginx/access-lists/{listID}', data); - expect(data).to.have.property('id', alphaListId); - }); - - expectStatus('/dashboard', alpha, '401'); - expectPage('/dashboard', { username: alpha.username, password: newPassword }, 'this is the dashboard page'); - alpha.password = newPassword; - - // Other locations are unaffected - expectStatus('/profile', null, '401'); - expectPage('/profile', beta, 'this is the profile page'); - }); - - it('Should inherit the host access list on locations without their own', () => { - // Host uses beta, /dashboard overrides with alpha, /missing has none and should inherit beta - cy.task('backendApiPut', { - token: token, - path: `/api/nginx/proxy-hosts/${hostId}`, - data: { - access_list_id: betaListId, - locations: [ - location('/dashboard', alphaListId), - location('/profile', betaListId), - location('/missing', 0), - ], - } - }).then((data) => { - // No swagger validation here: with a host access list set, the expanded - // access_list in the response has no proxy_host_count, which the schema requires - expect(data).to.have.property('access_list_id', betaListId); - expect(data.locations[2]).to.have.property('access_list_id', 0); - }); - - expectStatus('/', null, '401'); - expectStatus('/', alpha, '401'); - expectPage('/', beta, 'this is the index page'); - - // examplesite returns 404 for this path, once past the access list - expectStatus('/missing', null, '401'); - expectStatus('/missing', alpha, '401'); - expectStatus('/missing', beta, '404'); - - expectStatus('/dashboard', beta, '401'); - expectPage('/dashboard', alpha, 'this is the dashboard page'); - }); - - it('Should remove a deleted access list from the host and locations using it', () => { - cy.task('backendApiDelete', { - token: token, - path: `/api/nginx/access-lists/${betaListId}`, - }).then((data) => { - cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data); - expect(data).to.be.equal(true); - }); - - cy.task('backendApiGet', { - token: token, - path: `/api/nginx/proxy-hosts/${hostId}`, - }).then((data) => { - expect(data).to.have.property('access_list_id', 0); - expect(data.locations[0]).to.have.property('access_list_id', alphaListId); - expect(data.locations[1]).to.have.property('access_list_id', 0); - expect(data.locations[2]).to.have.property('access_list_id', 0); - }); - - expectPage('/', null, 'this is the index page'); - expectPage('/profile', null, 'this is the profile page'); - expectStatus('/missing', null, '404'); - - // Remaining location access list must still be enforced - expectStatus('/dashboard', null, '401'); - expectPage('/dashboard', alpha, 'this is the dashboard page'); - }); - - it('Should be able to delete the proxy host and remaining access list', () => { - cy.task('backendApiDelete', { - token: token, - path: `/api/nginx/proxy-hosts/${hostId}`, - }).then((data) => { - cy.validateSwaggerSchema('delete', 200, '/nginx/proxy-hosts/{hostID}', data); - expect(data).to.be.equal(true); - }); - - cy.task('backendApiDelete', { - token: token, - path: `/api/nginx/access-lists/${alphaListId}`, - }).then((data) => { - cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data); - expect(data).to.be.equal(true); - }); - }); - -}); diff --git a/test/docker/Dockerfile.website b/test/docker/Dockerfile.website deleted file mode 100644 index 311272d8c4..0000000000 --- a/test/docker/Dockerfile.website +++ /dev/null @@ -1,6 +0,0 @@ -FROM nginx:stable - -RUN rm -rf /etc/nginx/conf.d -COPY nginx /etc/nginx -COPY www /www -RUN chown -R nginx:nginx /www diff --git a/test/docker/nginx/conf.d/website123.conf b/test/docker/nginx/conf.d/website123.conf deleted file mode 100644 index 656f3ade42..0000000000 --- a/test/docker/nginx/conf.d/website123.conf +++ /dev/null @@ -1,29 +0,0 @@ -server { - listen 80; - server_name website1.example.com website2.example.com website3.example.com; - root /www; - index index.html; - - # redirect requests for .html URLs to their extensionless form - if ($request_uri ~ ^/index\.html(\?.*)?$) { - return 301 /$1; - } - if ($request_uri ~ ^/(.+)\.html(\?.*)?$) { - return 301 /$1$2; - } - - location / { - try_files $uri $uri.html $uri/ =404; - } - - error_page 404 /404.html; - location = /404.html { - internal; - } - - # deny access to .htaccess files, if Apache's document root - # concurs with nginx's one - location ~ /\.ht { - deny all; - } -} diff --git a/test/docker/nginx/nginx.conf b/test/docker/nginx/nginx.conf deleted file mode 100644 index 82366ca042..0000000000 --- a/test/docker/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log notice; -pid /run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - server_tokens off; - - log_format custom_combined '$remote_addr - $remote_user [$time_local] "$host" "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"'; - - access_log /var/log/nginx/access.log custom_combined; - sendfile on; - tcp_nopush on; - keepalive_timeout 65; - gzip on; - - set_real_ip_from 10.0.0.0/8; - set_real_ip_from 172.16.0.0/12; # Includes Docker subnet - set_real_ip_from 192.168.0.0/16; - - include include/*.conf; - include conf.d/*.conf; - include /sites/*/nginx.conf; -} diff --git a/test/docker/www/404.html b/test/docker/www/404.html deleted file mode 100644 index 389d3cafb1..0000000000 --- a/test/docker/www/404.html +++ /dev/null @@ -1,9 +0,0 @@ - - - 404 - - - 404 not found - - - diff --git a/test/docker/www/dashboard.html b/test/docker/www/dashboard.html deleted file mode 100644 index d26099e8f9..0000000000 --- a/test/docker/www/dashboard.html +++ /dev/null @@ -1,9 +0,0 @@ - - - Dashboard - - - this is the dashboard page - - - diff --git a/test/docker/www/index.html b/test/docker/www/index.html deleted file mode 100644 index 3a190926cb..0000000000 --- a/test/docker/www/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - Index - - - this is the index page - - - diff --git a/test/docker/www/profile.html b/test/docker/www/profile.html deleted file mode 100644 index 1058ca4a15..0000000000 --- a/test/docker/www/profile.html +++ /dev/null @@ -1,9 +0,0 @@ - - - Profile - - - this is the profile page - - - From bbd81f503ebb82cea61d24d94b17a06363913fa1 Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 17:39:18 +0200 Subject: [PATCH 36/55] add migration to convert upstreams location access lists into npmplus format Signed-off-by: Zoey --- .../20260913183944_nickname_default.js | 14 +++++ ...924163000_upstream_location_access_list.js | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 backend/migrations/20260924163000_upstream_location_access_list.js diff --git a/backend/migrations/20260913183944_nickname_default.js b/backend/migrations/20260913183944_nickname_default.js index 157402af61..9c97221b22 100644 --- a/backend/migrations/20260913183944_nickname_default.js +++ b/backend/migrations/20260913183944_nickname_default.js @@ -2,6 +2,14 @@ import { migrate as logger } from "../logger.js"; const migrateName = "nickname_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...`); @@ -12,6 +20,12 @@ const up = async (knex) => { logger.info(`[${migrateName}] user Table altered`); }; +/** + * Undo Migrate + * + * @param {Object} _knex + * @returns {Promise} + */ const down = (_knex) => { throw new Error(`[${migrateName}] You can't migrate down this one.`); }; diff --git a/backend/migrations/20260924163000_upstream_location_access_list.js b/backend/migrations/20260924163000_upstream_location_access_list.js new file mode 100644 index 0000000000..1ff80c09e4 --- /dev/null +++ b/backend/migrations/20260924163000_upstream_location_access_list.js @@ -0,0 +1,52 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "upstream_location_access_list"; + +/** + * Migrate + * + * @see https://knexjs.org/guide/migrations.html#migration-api + * + * @param {Object} knex + * @returns {Promise} + */ +const up = async (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + const validIds = new Set(await knex("access_list").where("is_deleted", 0).pluck("id")); + for (const row of await knex("proxy_host").where("is_deleted", 0).select("id", "locations")) { + const locations = Array.isArray(row.locations) ? row.locations : JSON.parse(row.locations || "[]"); + if (!locations.some((location) => location.access_list_id !== undefined)) continue; + for (const { access_list_id: id } of locations) + if (validIds.has(id)) + await knex("npmplus_proxy_host_access_list") + .insert({ proxy_host_id: row.id, access_list_id: id }) + .onConflict() + .ignore(); + await knex("proxy_host") + .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, + ), + ), + }); + } + + 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 }; From 7e09a1d0d43cae443e33d4e3ecab9948f88717e3 Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 17:51:18 +0200 Subject: [PATCH 37/55] update wording Signed-off-by: Zoey --- frontend/translations/ui/en.json | 2 +- frontend/translations/ui/nl.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/translations/ui/en.json b/frontend/translations/ui/en.json index 8ab1b917ab..2c3926a9e4 100644 --- a/frontend/translations/ui/en.json +++ b/frontend/translations/ui/en.json @@ -158,7 +158,7 @@ "host.flags.hsts-subdomains": "HSTS Subdomains+Preload", "host.flags.preserve-path": "Preserve Path", "host.flags.protocols": "Protocols", - "host.flags.send-noindex": "Send noindex header and block some user agents", + "host.flags.send-noindex": "Send noindex header and block some bot user agents", "host.flags.upstream-compression": "Enable compression by upstream", "host.flags.websockets-upgrade": "Websockets Support", "host.forward-port": "Forward Port", diff --git a/frontend/translations/ui/nl.json b/frontend/translations/ui/nl.json index ed4d23bd6b..f2b0168d34 100644 --- a/frontend/translations/ui/nl.json +++ b/frontend/translations/ui/nl.json @@ -154,7 +154,7 @@ "host.flags.hsts-subdomains": "HSTS Subdomeinen+Preload", "host.flags.preserve-path": "Pad behouden", "host.flags.protocols": "Protocollen", - "host.flags.send-noindex": "Noindex-header versturen en sommige user agents blokkeren", + "host.flags.send-noindex": "Noindex-header versturen en sommige bot user agents blokkeren", "host.flags.upstream-compression": "Upstream-compressie inschakelen", "host.flags.websockets-upgrade": "Websockets-ondersteuning", "host.forward-port": "Poort doorsturen", From fb6c197629227d20df84a796b398b0b58fa140cf Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 18:00:41 +0200 Subject: [PATCH 38/55] update nextcloud aio default config Signed-off-by: Zoey --- backend/setup.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/setup.js b/backend/setup.js index 3940fea0a4..1da2b781f9 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -193,6 +193,9 @@ const setupAio = async () => { forward_scheme: "http", forward_host: "127.0.0.1", forward_port: 11000, + npmplus_crowdsec_appsec: false, + npmplus_proxy_request_buffering: true, + npmplus_proxy_response_buffering: true, certificate_id: "new", ssl_forced: true, hsts_enabled: true, From bc8568f002959581f2fe69188d55a3758e567c3f Mon Sep 17 00:00:00 2001 From: Zoey Date: Thu, 24 Sep 2026 19:34:05 +0200 Subject: [PATCH 39/55] update crowdsec docs Signed-off-by: Zoey --- README.md | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e498ac3e70..f55b5ca82f 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,11 @@ docker compose up -d 12. Please report all (migration) issues you may have # Crowdsec - Note: If you don't [disable sharing in crowdsec](https://docs.crowdsec.net/docs/next/configuration/crowdsec_configuration/#sharing), you may need to mention that [this](https://docs.crowdsec.net/docs/central_api/intro/#signal-meta-data) is sent to crowdsec in your privacy policy. -1. Install crowdsec and the ZoeyVid/npmplus collection for example by using crowdsec container at the end of the compose.yaml, you may also want to install [this](https://app.crowdsec.net/hub/author/crowdsecurity/collections/http-dos), but be warned of false positives +1. Install crowdsec and the ZoeyVid/npmplus collection for example by using crowdsec container in the compose.yaml, you may also want to install some of these optional collections by editing the `COLLECTIONS` env: + - [crowdsecurity/http-dos](https://app.crowdsec.net/hub/author/crowdsecurity/collections/http-dos): detects http dos tools which the default http scenarios miss, but be warned of false positives + - [crowdsecurity/appsec-crs](https://app.crowdsec.net/hub/author/crowdsecurity/collections/appsec-crs): the OWASP Core Rule Set, it is loaded out-of-band, so matching requests are not blocked, but an IP triggering more than 5 rules gets banned, you also need to uncomment `crowdsecurity/crs` in step 3 + - [crowdsecurity/http-extended-context](https://app.crowdsec.net/hub/author/crowdsecurity/collections/http-extended-context): adds the attacked domain to the alert context, which is sent to crowdsec if your instance is enrolled in the console 2. Set LOGROTATE to `true` in your `compose.yaml` and redeploy 3. Open `/opt/crowdsec/conf/acquis.d/npmplus.yaml` (path may be different depending how you installed crowdsec) and fill it with: ```yaml @@ -88,21 +90,42 @@ labels: type: npmplus --- listen_addr: 0.0.0.0:7422 -appsec_config: crowdsecurity/appsec-default +appsec_configs: + - crowdsecurity/appsec-default +# - crowdsecurity/crs # only if you installed crowdsecurity/appsec-crs +# - crowdsecurity/appsec-bot-* # only if you set up bot detection, see below name: appsec source: appsec labels: type: appsec ``` -4. Make sure to use `network_mode: host` in your compose file for the NPMplus container -5. Run `docker exec crowdsec cscli bouncers add npmplus` and save the api key of the output -6. Open `/opt/npmplus/crowdsec/crowdsec.conf` -7. Set `ENABLED` to `true` -8. Use the output of step 5 as `API_KEY` -9. Save the file -10. Redeploy the `compose.yaml` -11. It is recommended to block at the earliest possible point, so if possible set up a firewall bouncer: https://docs.crowdsec.net/u/bouncers/firewall, make sure to also include the docker iptables in the firewall bouncer config -12. Note that when using crowdsec requests will always be buffered, so setting `proxy_(request_)buffering` to off will not work +4. Restart the crowdsec container +5. Make sure to use `network_mode: host` in your compose file for the NPMplus container +6. Run `docker exec crowdsec cscli bouncers add npmplus` and save the api key of the output +7. Open `/opt/npmplus/crowdsec/crowdsec.conf` +8. Set `ENABLED` to `true` +9. Use the output of step 6 as `API_KEY` +10. Save the file +11. Redeploy the `compose.yaml` +12. It is recommended to block at the earliest possible point, so if possible set up a firewall bouncer: https://docs.crowdsec.net/u/bouncers/firewall, make sure to also include the docker iptables in the firewall bouncer config +13. Note that when using crowdsec requests will always be buffered, so setting `proxy_(request_)buffering` to off will not work + +## Bot detection (optional) +Bot detection challenges every request without a valid cookie, so clients which can not solve it, like apps, get blocked. Full documentation: https://doc.crowdsec.net/docs/next/appsec/bot_detection/enable, the short version: +1. Add `crowdsecurity/appsec-bot-challenge`, `crowdsecurity/appsec-bot-challenge-strict` or `crowdsecurity/appsec-bot-challenge-permissive` to the `COLLECTIONS` env, depending on your wanted threshold +2. Uncomment `crowdsecurity/appsec-bot-*` in the acquisition file from step 3, the wildcard only matches appsec-configs you actually installed +3. Restart crowdsec + +## Rate limiting (optional) +If you added rate limiting (`limit_req_zone`/`limit_req`) through the advanced config or `/data/custom_nginx`, `crowdsecurity/nginx-req-limit-exceeded` from the ZoeyVid/npmplus collection needs the error log, since nginx only writes the `limiting requests, excess` lines there. Add it to the acquisition file from step 3: +```yaml +filenames: + - /opt/npmplus/nginx/logs/access.log + - /opt/npmplus/nginx/logs/error.log +labels: + type: npmplus +``` +nginx writes the client IP itself, so bans can not be forged. The request line however is written out as the client sent it, a crafted one can put any value into `target_fqdn`, so do not build whitelists on the domain of an error log alert. ## Use of external php-fpm (recommended) To set it per location: press the gear button, set the scheme to `path`, put in the path and paste the following in the new text field at the bottom, you need to adjust the last line: From 61fbc5a6d3c68b92d70cde506919373e81db0a46 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:38:38 +0000 Subject: [PATCH 40/55] dep updates --- Dockerfile | 6 +++--- frontend/package.json | 4 ++-- frontend/pnpm-lock.yaml | 28 ++++++++++++++-------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index 92c48a8c07..dc7da14c6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,10 @@ ARG DTR_VER=1.29.2 ARG ORP_VER=1.31.6 ARG ZNP_VER=1.30.0 -ARG NB_VER=35ec7c13cf2baf758845f727dae220acf9fb3445 # master -ARG NUB_VER=62f5496f34c847c97dd82bea060c5aa093d7e977 # main +ARG NB_VER=0b5a99975de43d0261b8fa8bb66f49e7dcaf7030 # master +ARG NUB_VER=2a6338902ad4e54f22d58f494d99d656880c0bcf # main ARG ZNM_VER=53927b6408ebf166496a3d79f016563f7f720cb0 # v0.4.0 -ARG NHUZFM_VER=658990e20a5f1cefbf760eb427741ce95b6eebc9 # main +ARG NHUZFM_VER=8137c6b05131d045619d1746d3bbc17623c9a8fb # main ARG NF_VER=047589e4dc0041517b8a47739fa960c430c4045e # v0.6.0 ARG HMNM_VER=0bf283ff92017acd616814b0e5153e0ccf93e2c9 # v0.40 ARG NDK_VER=bd44d16302273052d6005d7bdb55f74e23813de3 # v0.3.4 diff --git a/frontend/package.json b/frontend/package.json index 70f8972c1c..c957c9968c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "@ebay/nice-modal-react": "1.2.13", "@tabler/core": "1.5.1", "@tabler/icons-react": "3.48.0", - "@tanstack/react-query": "5.103.2", + "@tanstack/react-query": "5.104.0", "@tanstack/react-table": "9.2.4", "clsx": "2.1.1", "country-flag-icons": "1.6.20", @@ -21,7 +21,7 @@ "react": "19.3.0", "react-bootstrap": "2.10.10", "react-dom": "19.3.0", - "react-intl": "12.1.2", + "react-intl": "12.1.3", "react-router": "8.4.0", "react-select": "5.10.2", "react-toastify": "11.1.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d36fee1c41..a1a8301105 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 3.48.0 version: 3.48.0(react@19.3.0) '@tanstack/react-query': - specifier: 5.103.2 - version: 5.103.2(react@19.3.0) + specifier: 5.104.0 + version: 5.104.0(react@19.3.0) '@tanstack/react-table': specifier: 9.2.4 version: 9.2.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -54,8 +54,8 @@ importers: specifier: 19.3.0 version: 19.3.0(react@19.3.0) react-intl: - specifier: 12.1.2 - version: 12.1.2(@types/react@19.3.0)(react@19.3.0) + specifier: 12.1.3 + version: 12.1.3(@types/react@19.3.0)(react@19.3.0) react-router: specifier: 8.4.0 version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -428,11 +428,11 @@ packages: '@tabler/icons@3.48.0': resolution: {integrity: sha512-lQk06gVHNVBnJp0UOgtth54mcZBJM7rdYKsAxKOProj1bbSAx+/xiuOWfHlyvgdL3M+Y+I0HINrjHa3ygp8eXQ==} - '@tanstack/query-core@5.103.2': - resolution: {integrity: sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==} + '@tanstack/query-core@5.104.0': + resolution: {integrity: sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==} - '@tanstack/react-query@5.103.2': - resolution: {integrity: sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==} + '@tanstack/react-query@5.104.0': + resolution: {integrity: sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==} peerDependencies: react: ^18 || ^19 @@ -836,8 +836,8 @@ packages: react-fast-compare@2.0.4: resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==} - react-intl@12.1.2: - resolution: {integrity: sha512-+vRvVasUvksPtilKw00ayU5/HrXD+/XmlN7CG5782nkK8GhUc59uw4GsAzqBkf90Ei6a+4JSAgB1AFLS7kEDDg==} + react-intl@12.1.3: + resolution: {integrity: sha512-o14ogaAGSaYZMpyXcEnweedO2QoD1I5EdrZXq1iy+rFfhLWf8Nmr0YDCWtGwDAIPD/32ymnylpk7LGiS/AB5yw==} peerDependencies: '@types/react': '>=18.0.0' react: '>=18.0.0' @@ -1333,11 +1333,11 @@ snapshots: '@tabler/icons@3.48.0': {} - '@tanstack/query-core@5.103.2': {} + '@tanstack/query-core@5.104.0': {} - '@tanstack/react-query@5.103.2(react@19.3.0)': + '@tanstack/react-query@5.104.0(react@19.3.0)': dependencies: - '@tanstack/query-core': 5.103.2 + '@tanstack/query-core': 5.104.0 react: 19.3.0 '@tanstack/react-store@0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': @@ -1677,7 +1677,7 @@ snapshots: react-fast-compare@2.0.4: {} - react-intl@12.1.2(@types/react@19.3.0)(react@19.3.0): + react-intl@12.1.3(@types/react@19.3.0)(react@19.3.0): dependencies: '@formatjs/icu-messageformat-parser': 3.5.20 '@formatjs/intl': 6.1.2 From 6e69fd46527a0e5d59e3a623293b3c3bc8fc1354 Mon Sep 17 00:00:00 2001 From: Zoey2936 <75573284+Zoey2936@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:49:58 +0000 Subject: [PATCH 41/55] update nginx 1756 patch hash Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index dc7da14c6f..4fc6161e98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,7 +64,7 @@ RUN git-clone-commit.sh https://github.com/nginx/nginx "$NGINX_VER" /src/nginx & echo "73fdee62748f1624f87015a951a2480fd0d4fe566a81d92b852b51536d954b91 /src/nginx/1.patch" | sha256sum -c - && \ git apply /src/nginx/1.patch && \ wget -q https://patch-diff.githubusercontent.com/raw/nginx/nginx/pull/1756.patch -O /src/nginx/2.patch && \ - echo "2c86aca949907e0d9299108ea603d7ca7baebcb23da82fc56acd6330bf552617 /src/nginx/2.patch" | sha256sum -c - && \ + echo "5e9428aa81586c093440155c54669df444a0c61f8b0ab7056f20a7ebe21594fa /src/nginx/2.patch" | sha256sum -c - && \ git apply /src/nginx/2.patch && \ wget -q https://patch-diff.githubusercontent.com/raw/nginx/nginx/pull/1333.patch -O /src/nginx/3.patch && \ echo "01bf75b130b8f91075ec913a400a8debfab6da0ac609711c7d412ddbe59dd898 /src/nginx/3.patch" | sha256sum -c - && \ From 867e39e6b8542bd77249e60a10ff59a365f231e8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:33:59 +0000 Subject: [PATCH 42/55] Update dependency @tabler/core to v1.6.0 --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index c957c9968c..54a280c935 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "type": "module", "dependencies": { "@ebay/nice-modal-react": "1.2.13", - "@tabler/core": "1.5.1", + "@tabler/core": "1.6.0", "@tabler/icons-react": "3.48.0", "@tanstack/react-query": "5.104.0", "@tanstack/react-table": "9.2.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a1a8301105..fb860da37f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 1.2.13 version: 1.2.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tabler/core': - specifier: 1.5.1 - version: 1.5.1 + specifier: 1.6.0 + version: 1.6.0 '@tabler/icons-react': specifier: 3.48.0 version: 3.48.0(react@19.3.0) @@ -416,9 +416,14 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tabler/core@1.5.1': - resolution: {integrity: sha512-PI9rJq4H4lBh53YP/J+m5Uz6lqVQbsGPmWCMN34IP4KQQ/wy28YMO6a3Eiz1cQHdFFr+3MlU3yYhXdzvFGJfqw==} + '@tabler/core@1.6.0': + resolution: {integrity: sha512-AdYNV9SVWQy1h3SvcvRG0WH/N7objsf4tzRFVcRdKq7EUogF5hH9EkJup5SycBMrs7g7fGZHdY9h/s18rgy/Ww==} engines: {node: '>=20'} + peerDependencies: + vanilla-calendar-pro: ^3.3.2 + peerDependenciesMeta: + vanilla-calendar-pro: + optional: true '@tabler/icons-react@3.48.0': resolution: {integrity: sha512-OCeAlpaWHEMV+EsgdVeGQ3YuKfmfnThZhc++CaZ/ujYzi5+/DONDQlX4OGvH1ikYhIfmc0grI4otJgHy7QAvng==} @@ -1322,7 +1327,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@tabler/core@1.5.1': + '@tabler/core@1.6.0': dependencies: '@popperjs/core': 2.11.8 From c7b3631d3a23ce24afa721520e634bc0f3a4815a Mon Sep 17 00:00:00 2001 From: Zoey Date: Fri, 25 Sep 2026 16:44:57 +0200 Subject: [PATCH 43/55] use css classes if they exist Signed-off-by: Zoey --- frontend/index.html | 2 +- frontend/src/App.css | 77 +---------- frontend/src/components/EmptyData.jsx | 2 +- .../components/Form/AccessClientFields.jsx | 78 ++++++----- .../src/components/Form/DNSProviderFields.jsx | 17 +-- .../Form/DNSProviderFields.module.css | 7 - .../src/components/Form/LocationsFields.jsx | 62 +++++---- .../Form/LocationsFields.module.css | 3 - .../src/components/Form/NginxConfigField.jsx | 9 +- .../src/components/Form/SSLOptionsFields.jsx | 61 +-------- frontend/src/components/Loading.jsx | 3 +- frontend/src/components/Loading.module.css | 3 - frontend/src/components/LocalePicker.jsx | 9 +- .../src/components/LocalePicker.module.css | 8 -- frontend/src/components/Page.jsx | 3 +- frontend/src/components/Page.module.css | 5 - frontend/src/components/SiteContainer.jsx | 2 +- frontend/src/components/SiteHeader.jsx | 17 +-- frontend/src/components/SiteHeader.module.css | 8 -- frontend/src/components/SiteMenu.jsx | 75 +++++------ .../Table/Formatter/DomainsFormatter.jsx | 2 +- frontend/src/components/Table/TableBody.jsx | 7 +- frontend/src/components/ThemeSwitcher.jsx | 5 +- .../src/components/ThemeSwitcher.module.css | 15 --- frontend/src/context/ThemeContext.jsx | 2 - frontend/src/hooks/useDeadHost.js | 1 - frontend/src/hooks/useProxyHost.js | 5 - frontend/src/hooks/useRedirectionHost.js | 2 - frontend/src/main.jsx | 2 +- frontend/src/modals/AccessListModal.jsx | 4 +- .../src/modals/CustomCertificateModal.jsx | 38 +----- frontend/src/modals/DeadHostModal.jsx | 1 - frontend/src/modals/DeleteConfirmModal.jsx | 2 +- frontend/src/modals/EventDetailsModal.jsx | 10 +- frontend/src/modals/MfaModal.jsx | 36 ++++-- frontend/src/modals/PermissionsModal.jsx | 4 +- .../src/modals/PermissionsModal.module.css | 3 - frontend/src/modals/ProxyHostModal.jsx | 121 +++++------------- frontend/src/modals/RedirectionHostModal.jsx | 30 +---- frontend/src/modals/StreamModal.jsx | 17 ++- frontend/src/modals/UserModal.jsx | 4 +- frontend/src/notifications/Msg.jsx | 4 +- frontend/src/notifications/Msg.module.css | 14 -- frontend/src/notifications/helpers.jsx | 4 +- frontend/src/pages/Access/Table.jsx | 1 + frontend/src/pages/Certificates/Table.jsx | 8 +- frontend/src/pages/Login/index.jsx | 5 +- frontend/src/pages/Login/index.module.css | 3 - frontend/src/pages/Nginx/DeadHosts/Table.jsx | 1 + frontend/src/pages/Nginx/ProxyHosts/Table.jsx | 1 + .../pages/Nginx/RedirectionHosts/Table.jsx | 1 + frontend/src/pages/Nginx/Streams/Table.jsx | 11 +- frontend/src/pages/Settings/DefaultSite.jsx | 10 +- frontend/src/pages/Setup/index.jsx | 16 +-- frontend/src/pages/Setup/index.module.css | 10 -- frontend/src/pages/Users/Table.jsx | 1 + 56 files changed, 254 insertions(+), 598 deletions(-) delete mode 100644 frontend/src/components/Form/DNSProviderFields.module.css delete mode 100644 frontend/src/components/Form/LocationsFields.module.css delete mode 100644 frontend/src/components/Loading.module.css delete mode 100644 frontend/src/components/LocalePicker.module.css delete mode 100644 frontend/src/components/Page.module.css delete mode 100644 frontend/src/components/SiteHeader.module.css delete mode 100644 frontend/src/components/ThemeSwitcher.module.css delete mode 100644 frontend/src/modals/PermissionsModal.module.css delete mode 100644 frontend/src/notifications/Msg.module.css delete mode 100644 frontend/src/pages/Login/index.module.css delete mode 100644 frontend/src/pages/Setup/index.module.css diff --git a/frontend/index.html b/frontend/index.html index c19769696a..7f6ae64832 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -35,7 +35,7 @@ -
+
diff --git a/frontend/src/App.css b/frontend/src/App.css index 06d54c7ebf..23f4f17cdf 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,46 +1,5 @@ -:root { - color-scheme: light dark; -} - -.light { - color-scheme: light; -} -.dark { - color-scheme: dark; -} - -.modal-backdrop { - --tblr-backdrop-opacity: 0.8 !important; -} - -[data-bs-theme="dark"] .modal-content { - --tblr-modal-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; -} - -[data-bs-theme="dark"] .modal-backdrop { - --tblr-backdrop-bg: #000 !important; - --tblr-backdrop-opacity: 0.65 !important; -} - -.domain-name { - font-family: monospace; -} - -.mr-1 { - margin-right: 0.25rem; -} -.ml-1 { - margin-left: 0.25rem; -} - -::selection { - background-color: rgba(var(--tblr-primary-rgb), 0.4); - color: #ffffff; -} - .react-select-container { .react-select__control { - color: var(--tblr-body-color); background-color: var(--tblr-bg-forms); border: var(--tblr-border-width) solid var(--tblr-border-color); @@ -67,45 +26,15 @@ background-color: var(--tblr-bg-forms); .react-select__option { - background: rgba(var(--tblr-primary-rgb), 0.04); + background: color-mix(in oklab, var(--tblr-primary) 4%, transparent); color: inherit !important; &.react-select__option--is-focused { - background: rgba(var(--tblr-primary-rgb), 0.1); + background: color-mix(in oklab, var(--tblr-primary) 10%, transparent); } &.react-select__option--is-focused.react-select__option--is-selected { - background: rgba(var(--tblr-primary-rgb), 0.2); + background: color-mix(in oklab, var(--tblr-primary) 20%, transparent); } } } } - -label.row { - cursor: pointer; -} - -.input-group-select { - display: flex; - align-items: center; - padding: 0; - font-size: 0.875rem; - font-weight: 400; - line-height: 1.25rem; - color: var(--tblr-gray-500); - text-align: center; - white-space: nowrap; - background-color: var(--tblr-bg-surface-secondary); - border: var(--tblr-border-width) solid var(--tblr-border-color); - border-radius: var(--tblr-border-radius); - - .form-select { - border: none; - background-color: var(--tblr-bg-surface-secondary); - border-radius: var(--tblr-border-radius) 0 0 var(--tblr-border-radius); - } -} - -/* Fix for dropdown menus being clipped by table-responsive containers. */ -.table-responsive .dropdown { - position: static; -} diff --git a/frontend/src/components/EmptyData.jsx b/frontend/src/components/EmptyData.jsx index 965e0f3bc8..9591372522 100644 --- a/frontend/src/components/EmptyData.jsx +++ b/frontend/src/components/EmptyData.jsx @@ -28,7 +28,7 @@ function EmptyData({ -

+

{customAddBtn ? ( diff --git a/frontend/src/components/Form/AccessClientFields.jsx b/frontend/src/components/Form/AccessClientFields.jsx index b002a1559e..6dfec0e3b8 100644 --- a/frontend/src/components/Form/AccessClientFields.jsx +++ b/frontend/src/components/Form/AccessClientFields.jsx @@ -45,32 +45,31 @@ export function AccessClientFields({ initialValues, name = "clients" }) { return ( <> -

+

{values.slice(0, -1).map((client, idx) => (
- - - +
-

+

- - - + +

@@ -67,22 +66,16 @@ export function DNSProviderFields({ showBoundaryBox = false }) {