From 97c91fbdc91875f14d988d6735613396b7e3d9eb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 18 Sep 2026 23:55:51 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20destinationDomain?= =?UTF-8?q?=20to=20destinationHost=20to=20include=20the=20Scheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 8 ++-- server/db/pg/schema/schema.ts | 2 +- server/db/queries/verifySessionQueries.ts | 4 +- server/db/sqlite/schema/schema.ts | 2 +- server/routers/badger/verifySession.ts | 6 +-- server/routers/redirect/createRedirect.ts | 8 ++-- server/routers/redirect/getRedirect.ts | 4 +- server/routers/redirect/listRedirects.ts | 6 +-- server/routers/redirect/updateRedirect.ts | 8 ++-- server/routers/redirect/validation.ts | 15 +++++-- src/app/[orgId]/settings/redirects/page.tsx | 2 +- src/components/RedirectForm.tsx | 45 ++++++++++++--------- src/components/RedirectsTable.tsx | 6 +-- 13 files changed, 65 insertions(+), 51 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index b41d06eb6..b50b54134 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4421,12 +4421,12 @@ "redirectDeleteConfirm": "Confirm Delete Redirect", "redirectQuestionRemove": "Are you sure you want to remove this redirect?", "redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded.", - "redirectDestinationDomain": "Destination Domain", - "redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com", - "redirectDestinationDomainRequired": "Enter a destination domain", + "redirectDestinationHost": "Destination", + "redirectDestinationHostDescription": "Where requests are sent, including the scheme, such as https://example.com", + "redirectDestinationHostRequired": "Enter a destination", "redirectSameDomainAsSource": "Same domain as source", "redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path", - "redirectDestinationDomainInvalid": "Enter a valid domain, such as example.com", + "redirectDestinationHostInvalid": "Enter a valid destination with a scheme, such as https://example.com", "redirectMatchPathDescription": "Which incoming paths this redirect applies to", "redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.", "redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index cdf6e9f29..e07436aa3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -243,7 +243,7 @@ export const redirects = pgTable("redirects", { niceId: text("niceId").notNull(), name: varchar("name").notNull(), subdomain: varchar("subdomain"), - destinationDomain: varchar("destinationDomain").notNull(), + destinationHost: varchar("destinationHost").notNull(), // scheme://host[:port] pathMatchType: varchar("pathMatchType") .$type<"exact" | "prefix" | "regex">() .notNull() diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 7db986226..101e43722 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -60,7 +60,7 @@ export type RedirectByHost = { orgId: string; matchPath: string | null; pathMatchType: string; - destinationDomain: string; + destinationHost: string; rewritePath: string | null; rewritePathType: string | null; permanent: boolean; @@ -102,7 +102,7 @@ export async function getRedirectsByHost( orgId: redirects.orgId, matchPath: redirects.matchPath, pathMatchType: redirects.pathMatchType, - destinationDomain: redirects.destinationDomain, + destinationHost: redirects.destinationHost, rewritePath: redirects.rewritePath, rewritePathType: redirects.rewritePathType, permanent: redirects.permanent, diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0fff3b9f8..02f1faed6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -259,7 +259,7 @@ export const redirects = sqliteTable("redirects", { niceId: text("niceId").notNull(), name: text("name").notNull(), subdomain: text("subdomain"), - destinationDomain: text("destinationDomain").notNull(), + destinationHost: text("destinationHost").notNull(), // scheme://host[:port] pathMatchType: text("pathMatchType") .$type<"exact" | "prefix" | "regex">() .notNull() diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 6c6f36342..504eb9ac0 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1060,8 +1060,8 @@ async function findRedirect( } /** - * Destination for a configured redirect: the request's scheme and query are - * kept, the host is swapped for the destination domain and the path is run + * Destination for a configured redirect: the scheme and host come from the + * redirect's destination, the request's query is kept and the path is run * through the redirect's rewrite rules (if any). */ function buildRedirectUrl( @@ -1083,7 +1083,7 @@ function buildRedirectUrl( // originalRequestURL is validated as a URL, so this is only defensive } - return `${request.scheme}://${redirect.destinationDomain}${newPath}${search}`; + return `${redirect.destinationHost}${newPath}${search}`; } // Like a notAllowed login bounce, but the destination is the configured diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index bbcb3ac56..de682e740 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -10,7 +10,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { - redirectDestinationDomainSchema, + redirectDestinationHostSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -36,7 +36,7 @@ const bodySchema = z resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: redirectDestinationDomainSchema, + destinationHost: redirectDestinationHostSchema, pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema.optional().nullable(), rewritePath: redirectRewritePathSchema.optional().nullable(), @@ -126,7 +126,7 @@ export async function createRedirect( resourceId, domainId, subdomain, - destinationDomain, + destinationHost, pathMatchType, matchPath, rewritePath, @@ -200,7 +200,7 @@ export async function createRedirect( resourceId: resourceId ?? null, domainId: domainId ?? null, subdomain: subdomain ?? null, - destinationDomain, + destinationHost, pathMatchType: pathMatchType ?? "regex", matchPath: matchPath ?? null, rewritePath: rewritePath ?? null, diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index a7454f7ca..f2eae2961 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -17,7 +17,7 @@ export type GetRedirectResponse = { niceId: string; name: string; subdomain: string | null; - destinationDomain: string; + destinationHost: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string | null; rewritePath: string | null; @@ -43,7 +43,7 @@ const redirectColumns = { niceId: redirects.niceId, name: redirects.name, subdomain: redirects.subdomain, - destinationDomain: redirects.destinationDomain, + destinationHost: redirects.destinationHost, pathMatchType: redirects.pathMatchType, matchPath: redirects.matchPath, rewritePath: redirects.rewritePath, diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 229e2bb66..0b214f4b5 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -17,7 +17,7 @@ export type ListRedirectsResponse = PaginatedResponse<{ niceId: string; name: string; subdomain: string | null; - destinationDomain: string; + destinationHost: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string | null; rewritePath: string | null; @@ -130,7 +130,7 @@ export async function listRedirects( or( like(sql`LOWER(${redirects.name})`, term), like(sql`LOWER(${redirects.matchPath})`, term), - like(sql`LOWER(${redirects.destinationDomain})`, term) + like(sql`LOWER(${redirects.destinationHost})`, term) )! ); } @@ -142,7 +142,7 @@ export async function listRedirects( niceId: redirects.niceId, name: redirects.name, subdomain: redirects.subdomain, - destinationDomain: redirects.destinationDomain, + destinationHost: redirects.destinationHost, pathMatchType: redirects.pathMatchType, matchPath: redirects.matchPath, rewritePath: redirects.rewritePath, diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 84afad2e5..31038a74c 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -11,7 +11,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import { redirectNiceIdSchema, - redirectDestinationDomainSchema, + redirectDestinationHostSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -37,7 +37,7 @@ const bodySchema = z.strictObject({ resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: redirectDestinationDomainSchema.optional(), + destinationHost: redirectDestinationHostSchema.optional(), pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema.optional().nullable(), rewritePath: redirectRewritePathSchema.optional().nullable(), @@ -253,8 +253,8 @@ export async function updateRedirect( if (body.subdomain !== undefined) { updateData.subdomain = body.subdomain; } - if (body.destinationDomain !== undefined) { - updateData.destinationDomain = body.destinationDomain; + if (body.destinationHost !== undefined) { + updateData.destinationHost = body.destinationHost; } if (body.pathMatchType !== undefined) { updateData.pathMatchType = body.pathMatchType; diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index 3f2d2e087..324ddf6ff 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -49,11 +49,20 @@ export const redirectRewritePathSchema = z.string().nonempty(); // Same range as target priorities; 100 means "let the system order it". export const redirectPrioritySchema = z.int().min(1).max(1000); -export const redirectDestinationDomainSchema = z +/** + * A destination is `scheme://host[:port]` with no path, query or fragment; + * the request path (after any rewrite) is appended to it by badger. + */ +export function isValidDestinationHost(value: string): boolean { + const match = /^https?:\/\/([^/:?#]+)(:\d{1,5})?$/.exec(value); + return match !== null && isValidDomain(match[1]); +} + +export const redirectDestinationHostSchema = z .string() .nonempty() - .refine(isValidDomain, { - message: "Invalid domain" + .refine(isValidDestinationHost, { + message: "Invalid destination, expected scheme://host such as https://example.com" }); /** diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 1533e4f16..434b0d470 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -49,7 +49,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { niceId: redirect.niceId, name: redirect.name, subdomain: redirect.subdomain, - destinationDomain: redirect.destinationDomain, + destinationHost: redirect.destinationHost, pathMatchType: redirect.pathMatchType, matchPath: redirect.matchPath, rewritePath: redirect.rewritePath, diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index a1c0a52bf..dedb245e8 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -40,8 +40,10 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { isValidDomain } from "@server/lib/validators"; -import { isValidRegex } from "@server/routers/redirect/validation"; +import { + isValidDestinationHost, + isValidRegex +} from "@server/routers/redirect/validation"; import { build } from "@server/build"; import { cn } from "@app/lib/cn"; import { CaretSortIcon } from "@radix-ui/react-icons"; @@ -119,14 +121,14 @@ export default function RedirectForm({ domainId: z.string().nullable(), subdomain: z.string().nullable(), resourceId: z.number().int().positive().nullable(), - destinationDomain: z + destinationHost: z .string() .trim() .min(1, { - message: t("redirectDestinationDomainRequired") + message: t("redirectDestinationHostRequired") }) - .refine(isValidDomain, { - message: t("redirectDestinationDomainInvalid") + .refine(isValidDestinationHost, { + message: t("redirectDestinationHostInvalid") }), pathMatchType: z.enum(["exact", "prefix", "regex"]), matchPath: z.string().trim().nullable(), @@ -196,7 +198,7 @@ export default function RedirectForm({ domainId: redirect?.domainId ?? null, subdomain: redirect?.subdomain ?? null, resourceId: redirect?.resourceId ?? null, - destinationDomain: redirect?.destinationDomain ?? "", + destinationHost: redirect?.destinationHost ?? "", pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE, matchPath: redirect?.matchPath ?? null, rewritePath: redirect?.rewritePath ?? null, @@ -209,29 +211,32 @@ export default function RedirectForm({ }); const attachTo = form.watch("attachTo"); + const ssl = form.watch("ssl"); const sourceFullDomain = attachTo === "domain" ? domainFullDomain : (selectedResource?.fullDomain ?? null); + // Resource-attached redirects inherit the resource's ssl setting + const sourceSsl = + attachTo === "domain" ? ssl : (selectedResource?.ssl ?? true); + const sourceHost = sourceFullDomain + ? `${sourceSsl ? "https" : "http"}://${sourceFullDomain}` + : null; // Mirror is UI-only state; on edit, infer it from whether the saved // destination already equals the source host. const [sameDomainAsSource, setSameDomainAsSource] = useState( - Boolean( - redirect && - sourceFullDomain && - redirect.destinationDomain === sourceFullDomain - ) + Boolean(redirect && sourceHost && redirect.destinationHost === sourceHost) ); useEffect(() => { - if (sameDomainAsSource && sourceFullDomain) { - form.setValue("destinationDomain", sourceFullDomain, { + if (sameDomainAsSource && sourceHost) { + form.setValue("destinationHost", sourceHost, { shouldValidate: true }); } - }, [sameDomainAsSource, sourceFullDomain, form]); + }, [sameDomainAsSource, sourceHost, form]); const pathMatchType = form.watch("pathMatchType"); const rewritePath = form.watch("rewritePath"); const rewritePathType = form.watch("rewritePathType"); @@ -253,7 +258,7 @@ export default function RedirectForm({ values.attachTo === "domain" ? values.subdomain || null : null, resourceId: values.attachTo === "resource" ? values.resourceId : null, - destinationDomain: values.destinationDomain.trim(), + destinationHost: values.destinationHost.trim(), pathMatchType: values.pathMatchType, matchPath: values.matchPath?.trim() || null, rewritePath: values.rewritePath?.trim() || null, @@ -674,18 +679,18 @@ export default function RedirectForm({ ( {t( - "redirectDestinationDomain" + "redirectDestinationHost" )} {t( - "redirectDestinationDomainDescription" + "redirectDestinationHostDescription" )} diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 2249c9b43..f4ccee7aa 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -50,7 +50,7 @@ export type RedirectRow = { niceId: string; name: string; subdomain: string | null; - destinationDomain: string; + destinationHost: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string | null; rewritePath: string | null; @@ -301,7 +301,7 @@ export default function RedirectsTable({ }, { id: "destination", - accessorKey: "destinationDomain", + accessorKey: "destinationHost", friendlyName: t("redirectDestination"), header: () => ( {t("redirectDestination")} @@ -310,7 +310,7 @@ export default function RedirectsTable({ const redirect = row.original; return ( - {redirect.destinationDomain} + {redirect.destinationHost} {redirect.rewritePath && ( {redirect.rewritePathType === "prefix"