mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 06:59:06 +01:00
Merge branch 'dev' into feat/redirect-tables
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./features";
|
||||
export * from "./limitsService";
|
||||
export * from "./getOrgTierData";
|
||||
export * from "./createCustomer";
|
||||
export * from "./linkEmailOrg";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function linkEmailOrg(
|
||||
orgId: string,
|
||||
email: string | null | undefined
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in New Issue
Block a user