Merge branch 'dev' into feat/redirect-tables

This commit is contained in:
Fred KISSIE
2026-09-22 22:36:53 +02:00
66 changed files with 1715 additions and 371 deletions
+42 -5
View File
@@ -1,4 +1,4 @@
import { formatBackupTimestamp } from "./backupFileName";
import { formatBackupFileName, formatBackupTimestamp } from "./backupFileName";
import { assertEquals } from "@test/assert";
// Local-time constructors are used throughout, matching formatBackupTimestamp,
@@ -29,7 +29,9 @@ function testMonthIsOneIndexed() {
}
{
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
const result = formatBackupTimestamp(
new Date(2026, 11, 31, 23, 59, 59)
);
assertEquals(
result,
"2026-12-31_23-59-59",
@@ -73,9 +75,7 @@ function testNamesSortChronologically() {
new Date(2026, 11, 31, 23, 59, 59)
];
const sorted = taken
.map((date) => formatBackupTimestamp(date))
.sort();
const sorted = taken.map((date) => formatBackupTimestamp(date)).sort();
assertEquals(
sorted.join(","),
@@ -89,11 +89,48 @@ function testNamesSortChronologically() {
);
}
function testFormatBackupFileName() {
console.log("Running backup file name formatting tests...");
const date = new Date(2026, 8, 12, 20, 35, 56);
// With semver version string without leading 'v'
assertEquals(
formatBackupFileName("1.22.0", date),
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
"Filename must include timestamp and prefixed version tag"
);
// With version string already containing 'v'
assertEquals(
formatBackupFileName("v1.22.0", date),
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
"Filename must not duplicate 'v' prefix if already present"
);
// Without version (fallback/default)
assertEquals(
formatBackupFileName(undefined, date),
"db_2026-09-12_20-35-56.sqlite",
"Filename without version must match default timestamped format"
);
// Distinct versions within the exact same second do not collide
const sameSecondFile1 = formatBackupFileName("1.21.0", date);
const sameSecondFile2 = formatBackupFileName("1.22.0", date);
if (sameSecondFile1 === sameSecondFile2) {
throw new Error(
"Backup file names for different versions in the same second must not collide"
);
}
}
// Run all tests
try {
testMonthIsOneIndexed();
testEveryFieldIsZeroPadded();
testNamesSortChronologically();
testFormatBackupFileName();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
+23
View File
@@ -26,3 +26,26 @@ export function formatBackupTimestamp(date: Date = new Date()): string {
return `${datePart}_${timePart}`;
}
/**
* Builds the full database backup file name, including timestamp and optional version tag.
*
* When a migration version is provided, the filename includes `_v<version>`,
* preventing collisions between multiple migrations running in the same second and making it easy
* to identify the migration state contained in the backup.
*
* @param version Optional migration version being run.
* @param date The moment the backup is being taken. Defaults to now.
* @returns A filename of the form `db_YYYY-MM-DD_HH-MM-SS_v<version>.sqlite` or `db_YYYY-MM-DD_HH-MM-SS.sqlite`.
*/
export function formatBackupFileName(
version?: string,
date: Date = new Date()
): string {
const timestamp = formatBackupTimestamp(date);
if (version) {
const versionTag = version.startsWith("v") ? version : `v${version}`;
return `db_${timestamp}_${versionTag}.sqlite`;
}
return `db_${timestamp}.sqlite`;
}
+1
View File
@@ -3,3 +3,4 @@ export * from "./features";
export * from "./limitsService";
export * from "./getOrgTierData";
export * from "./createCustomer";
export * from "./linkEmailOrg";
+6
View File
@@ -0,0 +1,6 @@
export async function linkEmailOrg(
orgId: string,
email: string | null | undefined
): Promise<void> {
return;
}
+33 -8
View File
@@ -48,7 +48,13 @@ import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
parseHttpMethodList
} from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -255,10 +261,18 @@ export async function updatePublicResources(
resourceData.ssl == undefined || resourceData.ssl == null
? true
: resourceData.ssl;
let headers = "";
if (resourceData.headers) {
headers = JSON.stringify(resourceData.headers);
}
// `headers` is a deprecated alias for `requestHeaders`
const mergedRequestHeaders = [
...(resourceData.headers ?? []),
...(resourceData.requestHeaders ?? [])
];
const requestHeaders =
mergedRequestHeaders.length > 0
? JSON.stringify(mergedRequestHeaders)
: null;
const responseHeaders = resourceData.responseHeaders?.length
? JSON.stringify(resourceData.responseHeaders)
: null;
if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed(
@@ -397,7 +411,8 @@ export async function updatePublicResources(
? resourceData.auth["whitelist-users"].length >
0
: false,
headers: headers || null,
requestHeaders,
responseHeaders,
applyRules:
resourceData.rules &&
resourceData.rules.length > 0,
@@ -593,7 +608,8 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName:
resourceData["tls-server-name"] || null,
headers: headers || null,
requestHeaders,
responseHeaders,
maintenanceModeEnabled:
resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type,
@@ -1216,7 +1232,8 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null,
ssl: resourceSsl,
headers: headers || null,
requestHeaders,
responseHeaders,
applyRules:
resourceData.rules && resourceData.rules.length > 0,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
@@ -1453,6 +1470,10 @@ function getRuleValue(match: string, value: string) {
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase();
}
// normalize the method list so it is stored as "POST,PUT"
if (match === "METHOD") {
return parseHttpMethodList(value).join(",");
}
return value;
}
@@ -1473,6 +1494,10 @@ function validateRule(rule: any) {
if (!isValidRegionId(rule.value)) {
throw new Error(`Invalid region ID provided: ${rule.value}`);
}
} else if (rule.match === "method") {
if (!isValidHttpMethodList(rule.value)) {
throw new Error(`Invalid HTTP method provided: ${rule.value}`);
}
}
}
+16 -12
View File
@@ -19,7 +19,13 @@ import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
ResourceRuleMatchType
} from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -66,6 +72,13 @@ export async function updateResourcePolicies(
throw new Error(
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (
rule.match === "method" &&
!isValidHttpMethodList(rule.value)
) {
throw new Error(
`Invalid HTTP method provided in resource policy '${policyNiceId}': ${rule.value}`
);
}
}
@@ -339,17 +352,8 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
return "PASS";
}
function getRuleMatch(
input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
function getRuleMatch(input: string): ResourceRuleMatchType {
return input.toUpperCase() as ResourceRuleMatchType;
}
async function syncRolePolicies(
+27 -2
View File
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { isValidHttpMethodList } from "@server/lib/validators";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import {
@@ -127,7 +128,16 @@ export const AuthSchema = z.object({
export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
match: z.enum([
"cidr",
"path",
"ip",
"country",
"country_is_not",
"asn",
"region",
"method"
]),
value: z.coerce.string(),
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
@@ -207,6 +217,19 @@ export const RuleSchema = z
message:
"Value must be a valid UN M.49 region or subregion ID when match is 'region'"
}
)
.refine(
(rule) => {
if (rule.match === "method") {
return isValidHttpMethodList(rule.value);
}
return true;
},
{
path: ["value"],
message:
"Value must be a comma-separated list of HTTP methods when match is 'method', e.g. 'POST,PUT'"
}
);
export const HeaderSchema = z.object({
@@ -303,7 +326,9 @@ export const PublicResourceSchema = z
auth: AuthSchema.optional(),
"host-header": z.string().optional(),
"tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(),
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
requestHeaders: z.array(HeaderSchema).optional(),
responseHeaders: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(),
+6 -1
View File
@@ -20,6 +20,11 @@ function getSegmentRegex(patternPart: string): RegExp {
// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/`
// or `/public/../admin/` is matched as `/admin/`, not as a literal segment
// or a wildcard-swallowed sequence under `/public/*`.
//
// Applied to both the request path and the rule pattern: the pattern
// validator only accepts spaces / non-ASCII in percent-encoded form, so a
// rule like `/my%20docs/*` must be compared against the decoded segment
// `my docs`, not the literal text `my%20docs`.
function decodeAndResolvePath(p: string): string[] {
const rawParts = p.split("/").filter(Boolean);
@@ -48,7 +53,7 @@ function decodeAndResolvePath(p: string): string[] {
}
export function isPathAllowed(pattern: string, path: string): boolean {
const patternParts = pattern.split("/").filter(Boolean);
const patternParts = decodeAndResolvePath(pattern);
const pathParts = decodeAndResolvePath(path);
function matchSegments(
+6 -3
View File
@@ -97,7 +97,8 @@ export async function getTraefikConfig(
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
headers: resources.headers,
requestHeaders: resources.requestHeaders,
responseHeaders: resources.responseHeaders,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
@@ -227,7 +228,8 @@ export async function getTraefikConfig(
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
targets: [],
headers: row.headers,
requestHeaders: row.requestHeaders,
responseHeaders: row.responseHeaders,
proxyProtocol: row.proxyProtocol,
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
path: row.path, // the targets will all have the same path
@@ -482,7 +484,8 @@ export async function getTraefikConfig(
// Handle custom headers middleware
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.headers,
resource.requestHeaders,
resource.responseHeaders,
resource.setHostHeader,
resource.resourceId
);
+57 -25
View File
@@ -1,46 +1,78 @@
import logger from "@server/logger";
function parseHeaders(
headers: string,
label: string,
resourceId: number
): { name: string; value: string }[] {
try {
return JSON.parse(headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse ${label} for resource ${resourceId}: ${e}`
);
return [];
}
}
/**
* Build the customRequestHeaders middleware definition for a resource's
* custom headers + setHostHeader config. Returns null when there are no
* headers to set, so the caller can skip attaching the middleware.
* Build the custom headers middleware definition for a resource's
* custom request/response headers + setHostHeader config. Returns null when
* there are no headers to set, so the caller can skip attaching the
* middleware.
*/
export function buildCustomHeadersMiddleware(
headers: string | null | undefined,
requestHeaders: string | null | undefined,
responseHeaders: string | null | undefined,
setHostHeader: string | null | undefined,
resourceId: number
): { headers: { customRequestHeaders: { [key: string]: string } } } | null {
const headersObj: { [key: string]: string } = {};
): {
headers: {
customRequestHeaders?: { [key: string]: string };
customResponseHeaders?: { [key: string]: string };
};
} | null {
const requestHeadersObj: { [key: string]: string } = {};
const responseHeadersObj: { [key: string]: string } = {};
if (headers) {
let headersArr: { name: string; value: string }[] = [];
try {
headersArr = JSON.parse(headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
if (requestHeaders) {
parseHeaders(requestHeaders, "requestHeaders", resourceId).forEach(
(header) => {
requestHeadersObj[header.name] = header.value;
}
);
}
if (setHostHeader) {
headersObj["Host"] = setHostHeader;
requestHeadersObj["Host"] = setHostHeader;
}
if (Object.keys(headersObj).length === 0) {
if (responseHeaders) {
parseHeaders(responseHeaders, "responseHeaders", resourceId).forEach(
(header) => {
responseHeadersObj[header.name] = header.value;
}
);
}
const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0;
const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0;
if (!hasRequestHeaders && !hasResponseHeaders) {
return null;
}
return {
headers: {
customRequestHeaders: headersObj
...(hasRequestHeaders && {
customRequestHeaders: requestHeadersObj
}),
...(hasResponseHeaders && {
customResponseHeaders: responseHeadersObj
})
}
};
}
+41 -2
View File
@@ -1,9 +1,10 @@
import {
getResourceRuleValueValidationError,
isValidDomain,
isValidUrlGlobPattern
isValidUrlGlobPattern,
parseHttpMethodList
} from "./validators";
import { assertEquals } from "@test/assert";
import { assertEquals, assertEqualsObj } from "@test/assert";
function runTests() {
console.log("Running domain validation tests...");
@@ -295,6 +296,44 @@ function runTests() {
"Invalid ASN should return an error"
);
// HTTP method validation tests
assertEquals(
getResourceRuleValueValidationError("METHOD", "POST"),
null,
"Single HTTP method should be valid"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", " post , Put "),
null,
"Method list should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", "PROPFIND"),
null,
"Extension methods such as the WebDAV verbs should be valid"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", ""),
"Invalid HTTP method provided",
"Empty method list should return an error"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", ",,"),
"Invalid HTTP method provided",
"Method list of only separators should return an error"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", "GET POST"),
"Invalid HTTP method provided",
"Space separated methods should return an error"
);
assertEqualsObj(
parseHttpMethodList(" get ,post, "),
["GET", "POST"],
"Method list should be normalized to uppercase without empty entries"
);
console.log("All tests passed!");
}
+42 -1
View File
@@ -76,9 +76,46 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"COUNTRY",
"COUNTRY_IS_NOT",
"ASN",
"REGION"
"REGION",
"METHOD"
] as const;
// The methods offered in the UI: the eight from RFC 9110 plus PATCH (RFC 5789)
// and QUERY (RFC 10008). A METHOD rule is not limited to these, since
// isValidHttpMethodList accepts any method token, so blueprints and the API can
// also target extension methods such as the WebDAV verbs.
export const HTTP_METHODS = [
"GET",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"TRACE",
"CONNECT",
"QUERY"
] as const;
// RFC 9110 token, minus the characters that would collide with the
// comma-separated list encoding.
const HTTP_METHOD_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
export function parseHttpMethodList(value: string): string[] {
return value
.split(",")
.map((method) => method.trim().toUpperCase())
.filter((method) => method.length > 0);
}
export function isValidHttpMethodList(value: string): boolean {
const methods = parseHttpMethodList(value);
return (
methods.length > 0 &&
methods.every((method) => HTTP_METHOD_REGEX.test(method))
);
}
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
export function getResourceRuleValueValidationError(
@@ -101,6 +138,10 @@ export function getResourceRuleValueValidationError(
return COUNTRIES.some((country) => country.code === value)
? null
: "Invalid country code provided";
case "METHOD":
return isValidHttpMethodList(value)
? null
: "Invalid HTTP method provided";
case "ASN":
const normalizedValue = value.trim().toUpperCase();
return /^AS\d+$/.test(normalizedValue) ||