mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-18 16:49:56 +02:00
✨ finish redirect traefik config
This commit is contained in:
@@ -4392,6 +4392,7 @@
|
|||||||
"redirectMatchPathDescription": "Which incoming paths this redirect applies to",
|
"redirectMatchPathDescription": "Which incoming paths this redirect applies to",
|
||||||
"redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.",
|
"redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.",
|
||||||
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
|
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
|
||||||
|
"redirectMatchPathInvalidRegex": "Match path must be a valid regular expression",
|
||||||
"redirectCreate": "Create Redirect",
|
"redirectCreate": "Create Redirect",
|
||||||
"redirectCreateDescription": "Forward requests matching a path to another URL",
|
"redirectCreateDescription": "Forward requests matching a path to another URL",
|
||||||
"redirectEditDescription": "Update how this redirect forwards incoming requests",
|
"redirectEditDescription": "Update how this redirect forwards incoming requests",
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export const redirects = pgTable("redirects", {
|
|||||||
.$type<"exact" | "prefix" | "regex">()
|
.$type<"exact" | "prefix" | "regex">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("regex"), // exact, prefix, regex
|
.default("regex"), // exact, prefix, regex
|
||||||
matchPath: varchar("matchPath").notNull().default(".*"),
|
matchPath: varchar("matchPath"),
|
||||||
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
|
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
|
||||||
// else, the original path will be kept
|
// else, the original path will be kept
|
||||||
rewritePathType: varchar("rewritePathType").$type<
|
rewritePathType: varchar("rewritePathType").$type<
|
||||||
|
|||||||
@@ -264,13 +264,13 @@ export const redirects = sqliteTable("redirects", {
|
|||||||
.$type<"exact" | "prefix" | "regex">()
|
.$type<"exact" | "prefix" | "regex">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("regex"), // exact, prefix, regex
|
.default("regex"), // exact, prefix, regex
|
||||||
matchPath: text("matchPath").notNull().default("*"),
|
matchPath: text("matchPath"),
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value,
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value,
|
||||||
// else, the original path will be kept
|
// else, the original path will be kept
|
||||||
rewritePathType: text("rewritePathType").$type<
|
rewritePathType: text("rewritePathType").$type<
|
||||||
"exact" | "prefix" | "regex" | "stripPrefix"
|
"exact" | "prefix" | "regex" | "stripPrefix"
|
||||||
>(), // exact, prefix, regex, stripPrefix
|
>(), // exact, prefix, regex, stripPrefix
|
||||||
|
priority: integer("priority").default(100),
|
||||||
permanent: integer("permanent", { mode: "boolean" })
|
permanent: integer("permanent", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -11,25 +11,24 @@ export type RedirectRouteRow = {
|
|||||||
/** Host the redirect listens on (resource fullDomain or subdomain.baseDomain). */
|
/** Host the redirect listens on (resource fullDomain or subdomain.baseDomain). */
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
hasSubdomain: boolean;
|
hasSubdomain: boolean;
|
||||||
|
attachedTo: "resource" | "domain";
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
wildcard: boolean | null;
|
wildcard: boolean | null;
|
||||||
ssl: boolean;
|
ssl: boolean;
|
||||||
matchPath: string;
|
matchPath: string | null;
|
||||||
pathMatchType: string;
|
pathMatchType: string;
|
||||||
priority: number | null;
|
priority: number | null;
|
||||||
domainCertResolver?: string | null;
|
domainCertResolver?: string | null;
|
||||||
preferWildcardCert?: boolean | null;
|
preferWildcardCert?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Traefik requires a service on every router, but a redirect router's
|
||||||
* Add Traefik routers for redirects. Like resources, every request is sent
|
// middleware chain always terminates the request with a 30x, so the service
|
||||||
* through badger, which looks up the redirect by host/path, applies any
|
// is never reached. noop@internal answers 418 if it ever is - treat that as
|
||||||
* path rewrite and answers with the redirect itself - Traefik only has to
|
// a bug in the middleware chain, not something to route around.
|
||||||
* match the host (+ path) and terminate TLS. Redirects have no backend, so
|
const NOOP_SERVICE = "noop@internal";
|
||||||
* the routers point at Traefik's built-in noop@internal service.
|
|
||||||
* TLS/cert-resolver handling differs between the OSS and private
|
|
||||||
* (pangolin-dns aware) config generators, so callers resolve that via
|
|
||||||
* resolveTls - returning null skips the redirect (no valid cert yet).
|
|
||||||
*/
|
|
||||||
export function buildRedirectConfig(params: {
|
export function buildRedirectConfig(params: {
|
||||||
config_output: any;
|
config_output: any;
|
||||||
redirects: RedirectRouteRow[];
|
redirects: RedirectRouteRow[];
|
||||||
@@ -56,7 +55,18 @@ export function buildRedirectConfig(params: {
|
|||||||
const routerMiddlewares = [badgerMiddlewareName, ...additionalMiddlewares];
|
const routerMiddlewares = [badgerMiddlewareName, ...additionalMiddlewares];
|
||||||
|
|
||||||
for (const redirect of redirects) {
|
for (const redirect of redirects) {
|
||||||
const routerName = `redirect-${redirect.redirectId}-router`;
|
const routerName = `${redirect.redirectId}-redirect-${redirect.name}-router`;
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Processing redirect ${redirect.name} with domain ${redirect.fullDomain}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!redirect.enabled) {
|
||||||
|
logger.debug(
|
||||||
|
`Redirect ${redirect.name} is disabled, skipping Traefik config`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let tls: any = {};
|
let tls: any = {};
|
||||||
if (redirect.ssl) {
|
if (redirect.ssl) {
|
||||||
@@ -70,7 +80,7 @@ export function buildRedirectConfig(params: {
|
|||||||
config_output.http.routers = {};
|
config_output.http.routers = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirect.pathMatchType === "regex") {
|
if (redirect.matchPath && redirect.pathMatchType === "regex") {
|
||||||
try {
|
try {
|
||||||
new RegExp(redirect.matchPath);
|
new RegExp(redirect.matchPath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,11 +109,13 @@ export function buildRedirectConfig(params: {
|
|||||||
redirect.pathMatchType
|
redirect.pathMatchType
|
||||||
) + (hasExplicitPriority ? 0 : 1);
|
) + (hasExplicitPriority ? 0 : 1);
|
||||||
|
|
||||||
if (redirect.ssl) {
|
// if resource is already attached to resource, we don't need to add the https redirect
|
||||||
|
// as it is already added in the resource traefik config
|
||||||
|
if (redirect.attachedTo !== "resource" && redirect.ssl) {
|
||||||
config_output.http.routers[`${routerName}-redirect`] = {
|
config_output.http.routers[`${routerName}-redirect`] = {
|
||||||
entryPoints: [httpEntrypoint],
|
entryPoints: [httpEntrypoint],
|
||||||
middlewares: [redirectHttpsMiddlewareName],
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
service: "noop@internal",
|
service: NOOP_SERVICE,
|
||||||
rule,
|
rule,
|
||||||
priority
|
priority
|
||||||
};
|
};
|
||||||
@@ -112,7 +124,7 @@ export function buildRedirectConfig(params: {
|
|||||||
config_output.http.routers[routerName] = {
|
config_output.http.routers[routerName] = {
|
||||||
entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint],
|
entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint],
|
||||||
middlewares: routerMiddlewares,
|
middlewares: routerMiddlewares,
|
||||||
service: "noop@internal",
|
service: NOOP_SERVICE,
|
||||||
rule,
|
rule,
|
||||||
priority,
|
priority,
|
||||||
...(redirect.ssl ? { tls } : {})
|
...(redirect.ssl ? { tls } : {})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Target } from "@server/db";
|
import type { Domain, Resource, Target } from "@server/db";
|
||||||
|
|
||||||
// Extended target type with site information, shared between the OSS and
|
// Target subset with site information, shared between the OSS and
|
||||||
// private getTraefikConfig implementations.
|
// private getTraefikConfig implementations.
|
||||||
export type TargetWithSite = Target & {
|
export type TargetWithSite = {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
targetId: number;
|
targetId: number;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
@@ -19,3 +19,42 @@ export type TargetWithSite = Target & {
|
|||||||
online: boolean;
|
online: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A resource grouped with its targets for router/service generation. Every
|
||||||
|
// target in a group shares the same path/rewrite config, so those columns
|
||||||
|
// live on the resource rather than on each target.
|
||||||
|
export type ResourceWithTargets = Pick<
|
||||||
|
Resource,
|
||||||
|
| "resourceId"
|
||||||
|
| "fullDomain"
|
||||||
|
| "ssl"
|
||||||
|
| "proxyPort"
|
||||||
|
| "subdomain"
|
||||||
|
| "domainId"
|
||||||
|
| "enabled"
|
||||||
|
| "stickySession"
|
||||||
|
| "tlsServerName"
|
||||||
|
| "setHostHeader"
|
||||||
|
| "enableProxy"
|
||||||
|
| "headers"
|
||||||
|
| "proxyProtocol"
|
||||||
|
| "wildcard"
|
||||||
|
| "mode"
|
||||||
|
| "maintenanceModeEnabled"
|
||||||
|
| "maintenanceModeType"
|
||||||
|
| "maintenanceTitle"
|
||||||
|
| "maintenanceMessage"
|
||||||
|
| "maintenanceEstimatedTime"
|
||||||
|
> &
|
||||||
|
Pick<Target, "path" | "pathMatchType" | "rewritePath" | "rewritePathType"> & {
|
||||||
|
/** Sanitized resource name used in router/service names */
|
||||||
|
name: string;
|
||||||
|
/** Sanitized resourceId + path config, unique per router */
|
||||||
|
key: string;
|
||||||
|
priority: number;
|
||||||
|
proxyProtocolVersion: number;
|
||||||
|
// Left-joined from the resource's domain, so absent when there is none
|
||||||
|
domainCertResolver: Domain["certResolver"] | null;
|
||||||
|
preferWildcardCert: Domain["preferWildcardCert"] | null;
|
||||||
|
targets: TargetWithSite[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import {
|
|||||||
} from "@server/lib/certificates";
|
} from "@server/lib/certificates";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import regionalCache from "#private/lib/cache";
|
import regionalCache from "#private/lib/cache";
|
||||||
import { TargetWithSite } from "@server/lib/traefik/types";
|
import { ResourceWithTargets } from "@server/lib/traefik/types";
|
||||||
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
|
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
|
||||||
import {
|
import {
|
||||||
buildHostRule,
|
buildHostRule,
|
||||||
@@ -218,7 +218,7 @@ export async function getTraefikConfig(
|
|||||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
|
|
||||||
// Group by resource and include targets with their unique site data
|
// Group by resource and include targets with their unique site data
|
||||||
const resourcesMap = new Map();
|
const resourcesMap = new Map<string, ResourceWithTargets>();
|
||||||
|
|
||||||
for (const row of resourcesWithTargetsAndSites) {
|
for (const row of resourcesWithTargetsAndSites) {
|
||||||
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
||||||
@@ -246,7 +246,7 @@ export async function getTraefikConfig(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("-");
|
.join("-");
|
||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
const key = sanitize(mapKey);
|
const key = sanitize(mapKey) ?? "";
|
||||||
|
|
||||||
if (!resourcesMap.has(mapKey)) {
|
if (!resourcesMap.has(mapKey)) {
|
||||||
const validation = validatePathRewriteConfig(
|
const validation = validatePathRewriteConfig(
|
||||||
@@ -300,7 +300,7 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add target with its associated site data
|
// Add target with its associated site data
|
||||||
resourcesMap.get(mapKey).targets.push({
|
resourcesMap.get(mapKey)!.targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -406,6 +406,8 @@ export async function getTraefikConfig(
|
|||||||
// domain; the domain join resolves to whichever one applies.
|
// domain; the domain join resolves to whichever one applies.
|
||||||
const redirectRows = await db
|
const redirectRows = await db
|
||||||
.select({
|
.select({
|
||||||
|
name: redirects.name,
|
||||||
|
enabled: redirects.enabled,
|
||||||
redirectId: redirects.redirectId,
|
redirectId: redirects.redirectId,
|
||||||
subdomain: redirects.subdomain,
|
subdomain: redirects.subdomain,
|
||||||
matchPath: redirects.matchPath,
|
matchPath: redirects.matchPath,
|
||||||
@@ -432,7 +434,10 @@ export async function getTraefikConfig(
|
|||||||
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
|
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.leftJoin(domainNamespaces, eq(domainNamespaces.domainId, domains.domainId))
|
.leftJoin(
|
||||||
|
domainNamespaces,
|
||||||
|
eq(domainNamespaces.domainId, domains.domainId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(redirects.enabled, true),
|
eq(redirects.enabled, true),
|
||||||
@@ -459,6 +464,8 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
redirectRoutes.push({
|
redirectRoutes.push({
|
||||||
|
enabled: row.enabled,
|
||||||
|
name: sanitize(row.name) || "",
|
||||||
redirectId: row.redirectId,
|
redirectId: row.redirectId,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
hasSubdomain: attachedToResource
|
hasSubdomain: attachedToResource
|
||||||
@@ -467,6 +474,7 @@ export async function getTraefikConfig(
|
|||||||
wildcard: row.resourceWildcard,
|
wildcard: row.resourceWildcard,
|
||||||
// Domain-attached redirects always get a certificate on creation
|
// Domain-attached redirects always get a certificate on creation
|
||||||
ssl: attachedToResource ? !!row.resourceSsl : true,
|
ssl: attachedToResource ? !!row.resourceSsl : true,
|
||||||
|
attachedTo: attachedToResource ? "resource" : "domain",
|
||||||
matchPath: row.matchPath,
|
matchPath: row.matchPath,
|
||||||
pathMatchType: row.pathMatchType,
|
pathMatchType: row.pathMatchType,
|
||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
@@ -475,6 +483,14 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.dir(
|
||||||
|
{
|
||||||
|
redirectRoutes,
|
||||||
|
redirectRows
|
||||||
|
},
|
||||||
|
{ depth: null }
|
||||||
|
);
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
@@ -552,7 +568,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// get the key and the resource
|
// get the key and the resource
|
||||||
for (const [, resource] of resourcesMap.entries()) {
|
for (const [, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets as TargetWithSite[];
|
const targets = resource.targets;
|
||||||
const key = resource.key;
|
const key = resource.key;
|
||||||
|
|
||||||
const routerName = `${key}-${resource.name}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
redirectMatchPathSchema,
|
redirectMatchPathSchema,
|
||||||
redirectPathMatchTypeSchema,
|
redirectPathMatchTypeSchema,
|
||||||
redirectRewritePathSchema,
|
redirectRewritePathSchema,
|
||||||
|
isValidMatchPath,
|
||||||
redirectRewritePathTypeSchema
|
redirectRewritePathTypeSchema
|
||||||
} from "@server/routers/redirect/validation";
|
} from "@server/routers/redirect/validation";
|
||||||
import { getUniqueRedirectName } from "@server/db/names";
|
import { getUniqueRedirectName } from "@server/db/names";
|
||||||
@@ -35,7 +36,7 @@ const bodySchema = z
|
|||||||
subdomain: z.string().nonempty().optional().nullable(),
|
subdomain: z.string().nonempty().optional().nullable(),
|
||||||
destinationDomain: redirectDestinationDomainSchema,
|
destinationDomain: redirectDestinationDomainSchema,
|
||||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||||
matchPath: redirectMatchPathSchema,
|
matchPath: redirectMatchPathSchema.optional().nullable(),
|
||||||
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||||
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
||||||
permanent: z.boolean().optional(),
|
permanent: z.boolean().optional(),
|
||||||
@@ -57,6 +58,10 @@ const bodySchema = z
|
|||||||
.refine((data) => Boolean(data.resourceId) !== Boolean(data.domainId), {
|
.refine((data) => Boolean(data.resourceId) !== Boolean(data.domainId), {
|
||||||
message: "Exactly one of resourceId or domainId must be provided",
|
message: "Exactly one of resourceId or domainId must be provided",
|
||||||
path: ["resourceId"]
|
path: ["resourceId"]
|
||||||
|
})
|
||||||
|
.refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), {
|
||||||
|
message: "matchPath must be a valid regular expression",
|
||||||
|
path: ["matchPath"]
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -187,7 +192,7 @@ export async function createRedirect(
|
|||||||
subdomain: subdomain ?? null,
|
subdomain: subdomain ?? null,
|
||||||
destinationDomain,
|
destinationDomain,
|
||||||
pathMatchType: pathMatchType ?? "regex",
|
pathMatchType: pathMatchType ?? "regex",
|
||||||
matchPath,
|
matchPath: matchPath ?? null,
|
||||||
rewritePath: rewritePath ?? null,
|
rewritePath: rewritePath ?? null,
|
||||||
rewritePathType: rewritePathType ?? null,
|
rewritePathType: rewritePathType ?? null,
|
||||||
permanent: permanent ?? false,
|
permanent: permanent ?? false,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type GetRedirectResponse = {
|
|||||||
subdomain: string | null;
|
subdomain: string | null;
|
||||||
destinationDomain: string;
|
destinationDomain: string;
|
||||||
pathMatchType: "exact" | "prefix" | "regex";
|
pathMatchType: "exact" | "prefix" | "regex";
|
||||||
matchPath: string;
|
matchPath: string | null;
|
||||||
rewritePath: string | null;
|
rewritePath: string | null;
|
||||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type ListRedirectsResponse = PaginatedResponse<{
|
|||||||
subdomain: string | null;
|
subdomain: string | null;
|
||||||
destinationDomain: string;
|
destinationDomain: string;
|
||||||
pathMatchType: "exact" | "prefix" | "regex";
|
pathMatchType: "exact" | "prefix" | "regex";
|
||||||
matchPath: string;
|
matchPath: string | null;
|
||||||
rewritePath: string | null;
|
rewritePath: string | null;
|
||||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
redirectMatchPathSchema,
|
redirectMatchPathSchema,
|
||||||
redirectPathMatchTypeSchema,
|
redirectPathMatchTypeSchema,
|
||||||
redirectRewritePathSchema,
|
redirectRewritePathSchema,
|
||||||
redirectRewritePathTypeSchema
|
redirectRewritePathTypeSchema,
|
||||||
|
isValidMatchPath
|
||||||
} from "@server/routers/redirect/validation";
|
} from "@server/routers/redirect/validation";
|
||||||
import { createCertificate } from "../certificates";
|
import { createCertificate } from "../certificates";
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ const bodySchema = z.strictObject({
|
|||||||
subdomain: z.string().nonempty().optional().nullable(),
|
subdomain: z.string().nonempty().optional().nullable(),
|
||||||
destinationDomain: redirectDestinationDomainSchema.optional(),
|
destinationDomain: redirectDestinationDomainSchema.optional(),
|
||||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||||
matchPath: redirectMatchPathSchema.optional(),
|
matchPath: redirectMatchPathSchema.optional().nullable(),
|
||||||
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||||
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
||||||
permanent: z.boolean().optional(),
|
permanent: z.boolean().optional(),
|
||||||
@@ -206,6 +207,22 @@ export async function updateRedirect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isValidMatchPath(
|
||||||
|
body.matchPath !== undefined
|
||||||
|
? body.matchPath
|
||||||
|
: existing.matchPath,
|
||||||
|
body.pathMatchType ?? existing.pathMatchType
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"matchPath must be a valid regular expression"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const updateData: Partial<typeof redirects.$inferInsert> = {};
|
const updateData: Partial<typeof redirects.$inferInsert> = {};
|
||||||
|
|
||||||
if (body.name !== undefined) {
|
if (body.name !== undefined) {
|
||||||
|
|||||||
@@ -19,7 +19,29 @@ export const redirectRewritePathTypeSchema = z.enum([
|
|||||||
"stripPrefix"
|
"stripPrefix"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const redirectMatchPathSchema = z.string().nonempty().default("*");
|
export const redirectMatchPathSchema = z.string().nonempty();
|
||||||
|
|
||||||
|
export function isValidRegex(pattern: string): boolean {
|
||||||
|
try {
|
||||||
|
new RegExp(pattern);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A regex match path is fed straight to `new RegExp` when building routes,
|
||||||
|
* so reject patterns that would throw there.
|
||||||
|
*/
|
||||||
|
export function isValidMatchPath(
|
||||||
|
matchPath: string | null | undefined,
|
||||||
|
pathMatchType: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
pathMatchType !== "regex" || !matchPath || isValidRegex(matchPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const redirectRewritePathSchema = z.string().nonempty();
|
export const redirectRewritePathSchema = z.string().nonempty();
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { isValidDomain } from "@server/lib/validators";
|
import { isValidDomain } from "@server/lib/validators";
|
||||||
|
import { isValidRegex } from "@server/routers/redirect/validation";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -65,7 +66,6 @@ import { Plus } from "lucide-react";
|
|||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const DEFAULT_MATCH_PATH = ".*";
|
|
||||||
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
|
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
|
||||||
|
|
||||||
export type ExistingRedirect = GetRedirectResponse["redirect"];
|
export type ExistingRedirect = GetRedirectResponse["redirect"];
|
||||||
@@ -127,7 +127,7 @@ export default function RedirectForm({
|
|||||||
message: t("redirectDestinationDomainInvalid")
|
message: t("redirectDestinationDomainInvalid")
|
||||||
}),
|
}),
|
||||||
pathMatchType: z.enum(["exact", "prefix", "regex"]),
|
pathMatchType: z.enum(["exact", "prefix", "regex"]),
|
||||||
matchPath: z.string().trim().min(1),
|
matchPath: z.string().trim().nullable(),
|
||||||
rewritePath: z.string().nullable(),
|
rewritePath: z.string().nullable(),
|
||||||
rewritePathType: z
|
rewritePathType: z
|
||||||
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
||||||
@@ -150,6 +150,17 @@ export default function RedirectForm({
|
|||||||
path: ["resourceId"]
|
path: ["resourceId"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
data.pathMatchType === "regex" &&
|
||||||
|
data.matchPath &&
|
||||||
|
!isValidRegex(data.matchPath)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: t("redirectMatchPathInvalidRegex"),
|
||||||
|
path: ["matchPath"]
|
||||||
|
});
|
||||||
|
}
|
||||||
// stripPrefix drops the matched prefix outright, so it is
|
// stripPrefix drops the matched prefix outright, so it is
|
||||||
// the one rewrite type that needs no replacement value.
|
// the one rewrite type that needs no replacement value.
|
||||||
if (
|
if (
|
||||||
@@ -179,7 +190,7 @@ export default function RedirectForm({
|
|||||||
resourceId: redirect?.resourceId ?? null,
|
resourceId: redirect?.resourceId ?? null,
|
||||||
destinationDomain: redirect?.destinationDomain ?? "",
|
destinationDomain: redirect?.destinationDomain ?? "",
|
||||||
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
|
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
|
||||||
matchPath: redirect?.matchPath ?? DEFAULT_MATCH_PATH,
|
matchPath: redirect?.matchPath ?? null,
|
||||||
rewritePath: redirect?.rewritePath ?? null,
|
rewritePath: redirect?.rewritePath ?? null,
|
||||||
rewritePathType: redirect?.rewritePathType ?? null,
|
rewritePathType: redirect?.rewritePathType ?? null,
|
||||||
permanent: redirect?.permanent ?? false,
|
permanent: redirect?.permanent ?? false,
|
||||||
@@ -234,7 +245,7 @@ export default function RedirectForm({
|
|||||||
values.attachTo === "resource" ? values.resourceId : null,
|
values.attachTo === "resource" ? values.resourceId : null,
|
||||||
destinationDomain: values.destinationDomain.trim(),
|
destinationDomain: values.destinationDomain.trim(),
|
||||||
pathMatchType: values.pathMatchType,
|
pathMatchType: values.pathMatchType,
|
||||||
matchPath: values.matchPath.trim(),
|
matchPath: values.matchPath?.trim() || null,
|
||||||
rewritePath: values.rewritePath?.trim() || null,
|
rewritePath: values.rewritePath?.trim() || null,
|
||||||
rewritePathType: values.rewritePathType,
|
rewritePathType: values.rewritePathType,
|
||||||
permanent: values.permanent,
|
permanent: values.permanent,
|
||||||
@@ -650,15 +661,16 @@ export default function RedirectForm({
|
|||||||
onChange={(
|
onChange={(
|
||||||
config
|
config
|
||||||
) => {
|
) => {
|
||||||
// matchPath and
|
// No match path
|
||||||
// pathMatchType are
|
// means the
|
||||||
// NOT NULL, so a
|
// redirect applies
|
||||||
// clear falls back
|
// to every path;
|
||||||
// to the defaults
|
// pathMatchType is
|
||||||
// rather than null.
|
// NOT NULL so it
|
||||||
|
// keeps a default.
|
||||||
field.onChange(
|
field.onChange(
|
||||||
config.path ||
|
config.path ||
|
||||||
DEFAULT_MATCH_PATH
|
null
|
||||||
);
|
);
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"pathMatchType",
|
"pathMatchType",
|
||||||
@@ -675,13 +687,22 @@ export default function RedirectForm({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
|
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
|
||||||
>
|
>
|
||||||
<PathMatchDisplay
|
{field.value ? (
|
||||||
value={{
|
<PathMatchDisplay
|
||||||
path: field.value,
|
value={{
|
||||||
pathMatchType:
|
path: field.value,
|
||||||
pathMatchType
|
pathMatchType:
|
||||||
}}
|
pathMatchType
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"matchPath"
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,7 +31,15 @@ import {
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useOptimistic,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
type ComponentRef
|
||||||
|
} from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
export type RedirectRow = {
|
export type RedirectRow = {
|
||||||
@@ -41,7 +49,7 @@ export type RedirectRow = {
|
|||||||
subdomain: string | null;
|
subdomain: string | null;
|
||||||
destinationDomain: string;
|
destinationDomain: string;
|
||||||
pathMatchType: "exact" | "prefix" | "regex";
|
pathMatchType: "exact" | "prefix" | "regex";
|
||||||
matchPath: string;
|
matchPath: string | null;
|
||||||
rewritePath: string | null;
|
rewritePath: string | null;
|
||||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
@@ -121,15 +129,9 @@ export default function RedirectsTable({
|
|||||||
return path.endsWith("/") ? `${path}*` : `${path}/*`;
|
return path.endsWith("/") ? `${path}*` : `${path}/*`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleEnabled(row: RedirectRow, enabled: boolean) {
|
async function toggleEnabled(enabled: boolean, redirectId: number) {
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.redirectId === row.redirectId ? { ...r, enabled } : r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post(`/org/${orgId}/redirects/${row.redirectId}`, {
|
await api.post(`/org/${orgId}/redirects/${redirectId}`, {
|
||||||
enabled
|
enabled
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
@@ -138,13 +140,6 @@ export default function RedirectsTable({
|
|||||||
});
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.redirectId === row.redirectId
|
|
||||||
? { ...r, enabled: row.enabled }
|
|
||||||
: r
|
|
||||||
)
|
|
||||||
);
|
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("redirectErrorUpdate"),
|
title: t("redirectErrorUpdate"),
|
||||||
@@ -288,11 +283,15 @@ export default function RedirectsTable({
|
|||||||
) : null}
|
) : null}
|
||||||
<code className="text-sm truncate">
|
<code className="text-sm truncate">
|
||||||
{host ?? ""}
|
{host ?? ""}
|
||||||
<span className="text-muted-foreground">
|
{redirect.matchPath && (
|
||||||
{redirect.pathMatchType === "prefix"
|
<span className="text-muted-foreground">
|
||||||
? withPrefixGlob(redirect.matchPath)
|
{redirect.pathMatchType === "prefix"
|
||||||
: redirect.matchPath}
|
? withPrefixGlob(
|
||||||
</span>
|
redirect.matchPath
|
||||||
|
)
|
||||||
|
: redirect.matchPath}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -338,11 +337,9 @@ export default function RedirectsTable({
|
|||||||
friendlyName: t("enabled"),
|
friendlyName: t("enabled"),
|
||||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Switch
|
<RedirectEnabledForm
|
||||||
checked={row.original.enabled}
|
redirect={row.original}
|
||||||
onCheckedChange={(checked) =>
|
onToggleEnabled={toggleEnabled}
|
||||||
toggleEnabled(row.original, checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -451,3 +448,36 @@ export default function RedirectsTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RedirectEnabledFormProps = {
|
||||||
|
redirect: RedirectRow;
|
||||||
|
onToggleEnabled: (val: boolean, redirectId: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RedirectEnabledForm({
|
||||||
|
redirect,
|
||||||
|
onToggleEnabled
|
||||||
|
}: RedirectEnabledFormProps) {
|
||||||
|
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
|
||||||
|
redirect.enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
const formRef = useRef<ComponentRef<"form">>(null);
|
||||||
|
|
||||||
|
async function submitAction(formData: FormData) {
|
||||||
|
const newEnabled = !(formData.get("enabled") === "on");
|
||||||
|
setOptimisticEnabled(newEnabled);
|
||||||
|
await onToggleEnabled(newEnabled, redirect.redirectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={submitAction} ref={formRef}>
|
||||||
|
<Switch
|
||||||
|
checked={optimisticEnabled}
|
||||||
|
disabled={optimisticEnabled !== redirect.enabled}
|
||||||
|
name="enabled"
|
||||||
|
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user