diff --git a/messages/en-US.json b/messages/en-US.json index 88302ecd7..58552e197 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -295,7 +295,8 @@ "resourceTypeDescription": "This controls the resource protocol and can’t be changed later.", "resourceTypeSearch": "Search resource types...", "resourceTypeNotFound": "No resource type found", - "resourceTypeHttpDescription": "Proxy web traffic over HTTPS using a domain name", + "resourceTypeHttp": "HTTP(S)", + "resourceTypeHttpDescription": "Proxy web traffic over HTTP or HTTPS using a domain name", "resourceTypeInferenceDescription": "Route AI requests through attached providers", "resourceTypeSshDescription": "Access an SSH server from the browser", "resourceTypeRdpDescription": "Access a remote desktop from the browser", @@ -1523,6 +1524,11 @@ "actionGetVirtualApiKey": "Get Virtual API Key", "actionListVirtualApiKeys": "List Virtual API Keys", "actionUpdateVirtualApiKey": "Update Virtual API Key", + "actionCreateRedirect": "Create Redirect", + "actionDeleteRedirect": "Delete Redirect", + "actionGetRedirect": "Get Redirect", + "actionListRedirects": "List Redirects", + "actionUpdateRedirect": "Update Redirect", "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", @@ -2150,6 +2156,7 @@ "resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits", "sidebarApiKeys": "API Keys", "sidebarOrgs": "Organizations", + "sidebarRedirects": "Redirects", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", "sidebarAllUsers": "Users", @@ -2456,6 +2463,7 @@ "domains": "Domains", "domainsDescription": "Create and manage domains available in the organization", "domainsSearch": "Search domains...", + "domainsNotFound": "No domains found", "domainAdd": "Add Domain", "domainAddDescription": "Register a new domain with to the organization", "domainCreate": "Create Domain", @@ -2598,6 +2606,8 @@ "billingModifyLicenses": "Modify License Subscription", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", + "redirectDomainNotFoundDescription": "This redirect is disabled because the domain no longer exists our system. Please set a new domain for this redirect or its attached resource domain.", + "redirectDomainSelectedDomainNotFound": "This redirect is disabled because its domain no longer exists. Select a different domain, or attach the redirect to a resource that has a valid domain.", "failed": "Failed", "createNewOrgDescription": "Create a new organization", "organization": "Organization", @@ -3565,7 +3575,7 @@ "pathMatchExactHelp": "Example: /api matches only /api", "pathMatchRegexHelp": "Example: ^/api/.* matches /api/anything", "pathRewriteModalTitle": "Configure Path Rewriting", - "pathRewriteModalDescription": "Transform the matched path before forwarding to the target.", + "pathRewriteModalDescription": "Transform the matched path before forwarding to the destination.", "pathRewriteType": "Rewrite Type", "pathRewritePrefixOption": "Prefix - Replace prefix", "pathRewriteExactOption": "Exact - Replace entire path", @@ -3582,6 +3592,7 @@ "pathRewriteExact": "Exact", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Strip", + "pathRewriteStripPrefix": "Strip Prefix", "pathRewriteStripLabel": "strip", "sidebarEnableEnterpriseLicense": "Enable Enterprise License", "cannotbeUndone": "This can not be undone.", @@ -3611,6 +3622,7 @@ "validEmail": "Valid email", "validSSO": "Valid SSO", "validVirtualAPIKey": "Valid Virtual API Key", + "allowedRedirect": "Allowed Redirect", "view": "View", "configManaged": "Config Managed", "connectedClient": "Connected Client", @@ -4481,6 +4493,64 @@ "sessionToolbarHide": "Hide toolbar", "actionUpdateSiteApprovals": "Update Site Approvals", "check": "Check", + "redirectsTitle": "Manage Redirects", + "redirectsDescription": "Forward requests from a path on your domains or resources to another URL.", + "redirectsSearch": "Search redirects...", + "redirectAdd": "Add Redirect", + "redirectSource": "Source", + "redirectDestination": "Destination", + "redirectAttachedTo": "Attached To", + "redirectFilterAnyDomain": "All domains", + "redirectFilterAnyResource": "All resources", + "redirectFilterDomainFallback": "Domain {id}", + "redirectFilterResourceFallback": "Resource {id}", + "redirectType": "Type", + "redirectTypePermanent": "Permanent (308)", + "redirectTypeTemporary": "Temporary (307)", + "redirectUpdated": "Redirect updated successfully", + "redirectErrorUpdate": "Failed to update redirect", + "redirectDeleted": "Redirect deleted successfully", + "redirectErrorDelete": "Failed to delete redirect", + "redirectDelete": "Delete Redirect", + "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.", + "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.", + "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", + "redirectMatchPathInvalidRegex": "Match path must be a valid regular expression", + "redirectPriorityInvalid": "Enter a whole number between 1 and 1000", + "redirectPriorityDescription": "Higher priority routes are evaluated first.", + "redirectCreate": "Create Redirect", + "redirectCreateDescription": "Forward requests matching a path to another URL.", + "redirectEditDescription": "Update how this redirect forwards incoming requests.", + "redirectGoBack": "Back to Redirects", + "redirectCreated": "Redirect created successfully", + "redirectErrorCreate": "Failed to create redirect", + "redirectSettings": "Redirect Rule", + "selectedRedirectDomain": "Selected Domain", + "selectedRedirectResource": "Selected Resource", + "redirectResourceNoDomain": "This resource has no domain", + "redirectSourceSectionDescription": "Choose the domain or resource, and which incoming paths, this redirect matches.", + "redirectDestinationSectionDescription": "Where matching requests are sent, and how the path changes", + "redirectSettingsDescription": "Set which paths to match and where to send them.", + "redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it.", + "redirectAttachDomain": "Domain", + "redirectAttachDomainDescription": "Apply this redirect to a whole domain.", + "redirectAttachResource": "Resource", + "redirectAttachResourceDescription": "Apply this redirect to a single resource.", + "redirectDomainRequired": "Select a domain to attach this redirect to", + "redirectResourceRequired": "Select a resource to attach this redirect to", + "redirectPermanent": "Permanent Redirect", + "redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers.", + "redirectSslDescription": "Serve this redirect over HTTPS. Turn off to match requests on plain HTTP.", + "redirectSslInheritedDescription": "Inherited from the attached resource's TLS setting.", "rulesErrorInvalidMethod": "Invalid HTTP method", "rulesErrorInvalidMethodDescription": "Select at least one HTTP method.", "rulesSelectMethods": "Select methods" diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 78b6afd0a..2cdbef8d3 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -211,7 +211,12 @@ export enum ActionsEnum { deleteVirtualApiKey = "deleteVirtualApiKey", getVirtualApiKey = "getVirtualApiKey", listVirtualApiKeys = "listVirtualApiKeys", - updateVirtualApiKey = "updateVirtualApiKey" + updateVirtualApiKey = "updateVirtualApiKey", + createRedirect = "createRedirect", + deleteRedirect = "deleteRedirect", + getRedirect = "getRedirect", + listRedirects = "listRedirects", + updateRedirect = "updateRedirect" } export async function checkUserActionPermission( diff --git a/server/db/names.ts b/server/db/names.ts index 37e851379..d044f1406 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -4,6 +4,7 @@ import { aiProviders, clients, db, + redirects, resourcePolicies, resources, siteResources @@ -140,6 +141,30 @@ export async function getUniqueProviderName(orgId: string): Promise { } } +export async function getUniqueRedirectName(orgId: string): Promise { + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + const name = generateName(); + + const redirectCount = await db + .select({ + niceId: redirects.niceId, + orgId: redirects.orgId + }) + .from(redirects) + .where(and(eq(redirects.niceId, name), eq(redirects.orgId, orgId))); + + if (redirectCount.length === 0) { + return name; + } + loops++; + } +} + export async function getUniqueResourcePolicyName( orgId: string ): Promise { diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 53f02b69e..9c6f883b9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -234,6 +234,41 @@ export const resources = pgTable( ] ); +export const redirects = pgTable("redirects", { + redirectId: serial("redirectId").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + resourceId: integer("resourceId").references(() => resources.resourceId, { + onDelete: "cascade" + }), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + subdomain: varchar("subdomain"), + destinationHost: varchar("destinationHost").notNull(), // scheme://host[:port] + pathMatchType: varchar("pathMatchType") + .$type<"exact" | "prefix" | "regex">() + .notNull() + .default("regex"), // exact, prefix, regex + matchPath: varchar("matchPath"), + rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value, + // else, the original path will be kept + rewritePathType: varchar("rewritePathType").$type< + "exact" | "prefix" | "regex" | "stripPrefix" + >(), // exact, prefix, regex, stripPrefix + priority: integer("priority").default(100), + permanent: boolean("permanent").notNull().default(false), + // Only consulted for domain-attached redirects; resource-attached ones + // inherit the resource's ssl setting. + ssl: boolean("ssl").notNull().default(true), + enabled: boolean("enabled").notNull().default(true) +}); + export const resourceAiProviders = pgTable( "resourceAiProviders", { @@ -2118,6 +2153,7 @@ export type ResourcePolicy = InferSelectModel; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; export type ResourcePolicyRule = InferSelectModel; +export type Redirect = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index a2ae61046..cf04d4b3f 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -35,10 +35,12 @@ import { resourcePolicyHeaderAuth, ResourcePolicyHeaderAuth, resourceWhitelist, - resourcePolicyWhiteList + resourcePolicyWhiteList, + redirects, + domains } from "@server/db"; import { alias } from "@server/db"; -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, not, or, sql } from "drizzle-orm"; import logger from "@server/logger"; export type ResourceWithAuth = { @@ -53,11 +55,80 @@ export type ResourceWithAuth = { org: Org; }; +export type RedirectByHost = { + redirectId: number; + orgId: string; + matchPath: string | null; + pathMatchType: string; + destinationHost: string; + rewritePath: string | null; + rewritePathType: string | null; + permanent: boolean; + priority: number | null; +}; + export type UserSessionWithUser = { session: any; user: any; }; +/** + * Enabled redirects listening on the given host, highest priority first. + * A redirect listens on its resource's fullDomain when attached to one, + * otherwise on subdomain.baseDomain (or the bare baseDomain) of its domain. + * Mirrors the host resolution in getTraefikConfig so badger agrees with + * what Traefik routed. + */ +export async function getRedirectsByHost( + host: string +): Promise { + // A literal "*." leading label matches any single subdomain, like + // wildcard resources do. + const parts = host.split("."); + const candidates = [host]; + for (let i = 1; i < parts.length; i++) { + candidates.push(`*.${parts.slice(i).join(".")}`); + } + + const redirectHost = sql`case + when ${redirects.resourceId} is not null then ${resources.fullDomain} + when ${redirects.subdomain} is null then ${domains.baseDomain} + else ${redirects.subdomain} || '.' || ${domains.baseDomain} + end`; + + return db + .select({ + redirectId: redirects.redirectId, + orgId: redirects.orgId, + matchPath: redirects.matchPath, + pathMatchType: redirects.pathMatchType, + destinationHost: redirects.destinationHost, + rewritePath: redirects.rewritePath, + rewritePathType: redirects.rewritePathType, + permanent: redirects.permanent, + priority: redirects.priority + }) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) + .where( + and( + eq(redirects.enabled, true), + // Traefik drops resource-attached redirects along with a + // disabled resource; do the same here. + or( + isNull(redirects.resourceId), + and( + eq(resources.enabled, true), + not(isNull(resources.domainId)) + ) + ), + inArray(redirectHost, candidates) + ) + ) + .orderBy(desc(redirects.priority)); +} + /** * Get resource by domain with pincode and password information */ diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 72b323158..50163ba50 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -252,6 +252,43 @@ export const resources = sqliteTable( (table) => [index("idx_resources_orgId").on(table.orgId)] ); +export const redirects = sqliteTable("redirects", { + redirectId: integer("redirectId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + resourceId: integer("resourceId").references(() => resources.resourceId, { + onDelete: "cascade" + }), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + niceId: text("niceId").notNull(), + name: text("name").notNull(), + subdomain: text("subdomain"), + destinationHost: text("destinationHost").notNull(), // scheme://host[:port] + pathMatchType: text("pathMatchType") + .$type<"exact" | "prefix" | "regex">() + .notNull() + .default("regex"), // exact, prefix, regex + matchPath: text("matchPath"), + rewritePath: text("rewritePath"), // if set, rewrites the path to this value, + // else, the original path will be kept + rewritePathType: text("rewritePathType").$type< + "exact" | "prefix" | "regex" | "stripPrefix" + >(), // exact, prefix, regex, stripPrefix + priority: integer("priority").default(100), + permanent: integer("permanent", { mode: "boolean" }) + .notNull() + .default(false), + // Only consulted for domain-attached redirects; resource-attached ones + // inherit the resource's ssl setting. + ssl: integer("ssl", { mode: "boolean" }).notNull().default(true), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) +}); + export const resourceAiProviders = sqliteTable( "resourceAiProviders", { @@ -2164,6 +2201,7 @@ export type ResourcePolicyHeaderAuth = InferSelectModel< >; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; +export type Redirect = InferSelectModel; export type AiProvider = InferSelectModel; export type AiModel = InferSelectModel; export type AiBudget = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index 055dae57b..7e3ed3261 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,7 @@ import { RemoteExitNode, Session, SiteResource, + Redirect, User, UserOrg, VirtualApiKey @@ -96,6 +97,7 @@ declare global { aiModel?: AiModel; aiBudget?: AiBudget; virtualApiKey?: VirtualApiKey; + redirect?: Redirect; orgPolicyAllowed?: boolean; } } diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 81a42ab4b..980837cf3 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,56 +1,58 @@ import { db, - targetHealthCheck, domains, - aiProviders, - resourceAiProviders, - siteResources, + exitNodes, + redirects, + resources, siteNetworks, - exitNodes + siteResources, + sites, + targetHealthCheck, + targets } from "@server/db"; +import regionalCache from "@server/lib/cache"; +import config from "@server/lib/config"; +import logger from "@server/logger"; import { and, + desc, eq, inArray, - or, - isNull, - ne, isNotNull, - desc, + isNull, + not, + or, sql } from "drizzle-orm"; -import logger from "@server/logger"; -import config from "@server/lib/config"; -import { resources, sites, targets } from "@server/db"; -import { applyPathRewriteMiddleware } from "./middleware"; -import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; -import regionalCache from "@server/lib/cache"; -import { TargetWithSite } from "./types"; +import { + AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME, + AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE, + AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE, + buildAiGatewayClientIpMiddleware, + buildAiGatewayHostHeaderMiddleware, + buildAiGatewayRouterAndService, + buildAiGatewayTrustMiddlewares, + getAiGatewayHost +} from "./aiGatewayMiddlewares"; +import { + buildBrowserGatewayConfig, + buildBrowserGatewayResourcesMap +} from "./browserGateway"; import { buildWildcardTls } from "./certResolver"; -import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule"; +import { buildCustomHeadersMiddleware } from "./headersMiddleware"; import { buildHttpLoadBalancerServers, buildLoadBalancingStrategy, buildStickySessionCookie, - buildTcpUdpLoadBalancerServers, - buildStickySessionIp + buildStickySessionIp, + buildTcpUdpLoadBalancerServers } from "./loadBalancer"; -import { buildCustomHeadersMiddleware } from "./headersMiddleware"; -import { - AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE, - AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE, - AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME, - getAiGatewayHost, - buildAiGatewayTrustMiddlewares, - buildAiGatewayClientIpMiddleware, - buildAiGatewayHostHeaderMiddleware, - buildAiGatewayRouterAndService -} from "./aiGatewayMiddlewares"; -import { - buildBrowserGatewayResourcesMap, - buildBrowserGatewayConfig -} from "./browserGateway"; +import { applyPathRewriteMiddleware } from "./middleware"; +import { buildRedirectConfig, RedirectRouteRow } from "./redirect"; +import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule"; import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias"; +import { TargetWithSite } from "./types"; +import { encodePath, sanitize, validatePathRewriteConfig } from "./utils"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; @@ -131,6 +133,7 @@ export async function getTraefikConfig( siteOnline: sites.online, subnet: sites.exitNodeSubnet, exitNodeId: sites.exitNodeId, + // Domain cert resolver fields domainCertResolver: domains.certResolver, preferWildcardCert: domains.preferWildcardCert @@ -331,12 +334,93 @@ export async function getTraefikConfig( ) ); + // Redirects have no targets/sites, so like inference resources they are + // queried separately and emitted on every exit node. A redirect listens + // either on a resource's fullDomain or on subdomain.baseDomain of a + // domain; the domain join resolves to whichever one applies. + const redirectRows = await db + .select({ + name: redirects.name, + enabled: redirects.enabled, + redirectId: redirects.redirectId, + subdomain: redirects.subdomain, + matchPath: redirects.matchPath, + pathMatchType: redirects.pathMatchType, + priority: redirects.priority, + ssl: redirects.ssl, + // Resource (when attached to one) + resourceId: resources.resourceId, + resourceFullDomain: resources.fullDomain, + resourceSubdomain: resources.subdomain, + resourceSsl: resources.ssl, + resourceWildcard: resources.wildcard, + // Domain (the redirect's own, or the resource's) + baseDomain: domains.baseDomain, + domainCertResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert + }) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin( + domains, + eq( + domains.domainId, + sql`coalesce(${redirects.domainId}, ${resources.domainId})` + ) + ) + .where( + and( + eq(redirects.enabled, true), + or( + isNull(redirects.resourceId), + and( + eq(resources.enabled, true), + not(isNull(resources.domainId)) + ) + ) + ) + ) + .orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering + + const redirectRoutes: RedirectRouteRow[] = []; + for (const row of redirectRows) { + const attachedToResource = row.resourceId !== null; + const fullDomain = attachedToResource + ? row.resourceFullDomain + : [row.subdomain, row.baseDomain].filter(Boolean).join("."); + if (!fullDomain) { + logger.debug( + `Redirect ${row.redirectId} has no host to listen on, skipping Traefik config` + ); + continue; + } + + redirectRoutes.push({ + enabled: row.enabled, + name: sanitize(row.name) || "", + redirectId: row.redirectId, + fullDomain, + hasSubdomain: attachedToResource + ? !!row.resourceSubdomain + : !!row.subdomain, + wildcard: row.resourceWildcard, + ssl: attachedToResource ? !!row.resourceSsl : row.ssl, + attachedTo: attachedToResource ? "resource" : "domain", + matchPath: row.matchPath, + pathMatchType: row.pathMatchType, + priority: row.priority, + domainCertResolver: row.domainCertResolver, + preferWildcardCert: row.preferWildcardCert + }); + } + // make sure we have at least one resource if ( resourcesMap.size === 0 && inferenceResources.length === 0 && browserGatewayResourcesMap.size === 0 && - siteResourcesWithFullDomain.length === 0 + siteResourcesWithFullDomain.length === 0 && + redirectRoutes.length === 0 ) { return {}; } @@ -527,6 +611,21 @@ export async function getTraefikConfig( } } + buildRedirectConfig({ + config_output, + redirects: redirectRoutes, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls: (redirect) => + buildWildcardTls({ + fullDomain: redirect.fullDomain, + hasSubdomain: redirect.hasSubdomain, + domainCertResolver: redirect.domainCertResolver, + preferWildcardCert: + redirect.preferWildcardCert || redirect.wildcard + }) + }); + if (browserGatewayUiUrl) { buildBrowserGatewayConfig({ config_output, diff --git a/server/lib/traefik/loadBalancer.test.ts b/server/lib/traefik/loadBalancer.test.ts index 2adb5f040..011f6a000 100644 --- a/server/lib/traefik/loadBalancer.test.ts +++ b/server/lib/traefik/loadBalancer.test.ts @@ -11,21 +11,12 @@ function makeTarget(ip: string, siteType: string): TargetWithSite { return { targetId: 1, resourceId: 1, - providerId: null, - siteId: 1, ip, port: 8080, internalPort: 9000, method: "http", enabled: true, health: "healthy", - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: 100, - mode: "http", - authToken: null, site: { siteId: 1, type: siteType, diff --git a/server/lib/traefik/loadBalancer.ts b/server/lib/traefik/loadBalancer.ts index 365756b37..8af343989 100644 --- a/server/lib/traefik/loadBalancer.ts +++ b/server/lib/traefik/loadBalancer.ts @@ -49,7 +49,7 @@ export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) { target.site.type === "wireguard" ) { return { - url: `${target.method}://${formatEndpoint(target.ip, target.port)}` + url: `${target.method}://${formatEndpoint(target.ip!, target.port!)}` }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; @@ -124,7 +124,7 @@ export function buildTcpUdpLoadBalancerServers(targets: TargetWithSite[]) { target.site.type === "wireguard" ) { return { - address: formatEndpoint(target.ip, target.port) + address: formatEndpoint(target.ip!, target.port!) }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; diff --git a/server/lib/traefik/middleware.test.ts b/server/lib/traefik/middleware.test.ts new file mode 100644 index 000000000..2023cae3c --- /dev/null +++ b/server/lib/traefik/middleware.test.ts @@ -0,0 +1,134 @@ +import { assertEquals } from "../../../test/assert"; +import { rewriteRequestPath } from "./middleware"; + +function runTests() { + console.log("Running rewriteRequestPath tests..."); + + // no rewrite configured + assertEquals( + rewriteRequestPath("/a/b", "/a", "prefix", null, null), + "/a/b", + "no rewrite type" + ); + + // exact rewrite + assertEquals( + rewriteRequestPath("/old", "/old", "exact", "/new", "exact"), + "/new", + "exact -> exact" + ); + assertEquals( + rewriteRequestPath("/old/x", "/old", "exact", "/new", "exact"), + "/old/x", + "exact rewrite only on exact match" + ); + assertEquals( + rewriteRequestPath("/old", "/old", "exact", "new", "exact"), + "/new", + "leading slash added to rewrite" + ); + assertEquals( + rewriteRequestPath("/anything", null, null, "/new", "exact"), + "/new", + "no match path + exact rewrite replaces path" + ); + + // prefix rewrite + assertEquals( + rewriteRequestPath("/old/a/b", "/old", "prefix", "/new", "prefix"), + "/new/a/b", + "prefix -> prefix keeps rest" + ); + assertEquals( + rewriteRequestPath("/old", "/old", "prefix", "/new", "prefix"), + "/new", + "prefix -> prefix bare" + ); + assertEquals( + rewriteRequestPath("/old", "/old", "exact", "/new", "prefix"), + "/new", + "exact -> prefix" + ); + assertEquals( + rewriteRequestPath( + "/api/v1/users", + "^/api/v1/(.*)", + "regex", + "/v2/$1", + "prefix" + ), + "/v2/users", + "regex -> prefix uses capture" + ); + + // regex rewrite + assertEquals( + rewriteRequestPath( + "/blog/2020/post", + "^/blog/(\\d+)/(.*)$", + "regex", + "/archive/$2-$1", + "regex" + ), + "/archive/post-2020", + "regex -> regex" + ); + assertEquals( + rewriteRequestPath("/old/x", "/old", "prefix", "/new$1", "regex"), + "/new/x", + "prefix -> regex has (.*) capture" + ); + assertEquals( + rewriteRequestPath("/old", "/old", "exact", "/new", "regex"), + "/new", + "exact -> regex" + ); + + // stripPrefix + assertEquals( + rewriteRequestPath("/old/a", "/old", "prefix", null, "stripPrefix"), + "/a", + "stripPrefix" + ); + assertEquals( + rewriteRequestPath("/old", "/old", "prefix", null, "stripPrefix"), + "/", + "stripPrefix to root" + ); + assertEquals( + rewriteRequestPath("/old/a", "/old", "prefix", "/new", "stripPrefix"), + "/new/a", + "stripPrefix + addPrefix" + ); + assertEquals( + rewriteRequestPath("/old", "/old", "exact", null, "stripPrefix"), + "/", + "stripPrefix exact" + ); + // Same result the replacePathRegex middleware produces for this config + // (regex `^/old` -> `/`), quirky double slash included. + assertEquals( + rewriteRequestPath("/old/a", "^/old", "regex", null, "stripPrefix"), + "//a", + "stripPrefix regex mirrors Traefik" + ); + assertEquals( + rewriteRequestPath("/old/a", "^/old/", "regex", null, "stripPrefix"), + "/a", + "stripPrefix regex with trailing slash" + ); + assertEquals( + rewriteRequestPath("/a/b", null, null, null, "stripPrefix"), + "/a/b", + "stripPrefix without match path is a no-op" + ); + + console.log("All rewriteRequestPath tests passed!"); +} + +try { + runTests(); +} catch (error) { + console.error("Test failed:", error); + process.exit(1); +} diff --git a/server/lib/traefik/middleware.ts b/server/lib/traefik/middleware.ts index 1c62442be..f96f855cf 100644 --- a/server/lib/traefik/middleware.ts +++ b/server/lib/traefik/middleware.ts @@ -194,6 +194,78 @@ export default function createPathRewriteMiddleware( return { middlewares }; } +/** + * Apply a path rewrite to a request path in-process, producing the same + * result the replacePathRegex / stripPrefix middlewares built above would. + * Used where Pangolin issues the redirect itself (badger) instead of + * handing it to Traefik. + */ +export function rewriteRequestPath( + requestPath: string, + path: string | null, + pathMatchType: string | null, + rewritePath: string | null, + rewritePathType: string | null +): string { + if (!rewritePathType) { + return requestPath; + } + + let target = rewritePath ?? ""; + if ( + rewritePathType !== "regex" && + target !== "" && + !target.startsWith("/") + ) { + target = `/${target}`; + } + + // Nothing was matched against, so there is nothing to strip or replace; + // an exact rewrite is the only one that still means something. + if (!path || !pathMatchType) { + return rewritePathType === "exact" ? target || "/" : requestPath; + } + + let matched = path; + if (pathMatchType !== "regex" && !matched.startsWith("/")) { + matched = `/${matched}`; + } + + const matchRegex = + pathMatchType === "regex" + ? matched + : pathMatchType === "prefix" + ? `^${escapeRegex(matched)}(.*)` + : `^${escapeRegex(matched)}$`; + + switch (rewritePathType) { + case "exact": + return requestPath.replace( + new RegExp(`^${escapeRegex(matched)}$`), + target + ); + case "prefix": + return requestPath.replace( + new RegExp(matchRegex), + pathMatchType === "prefix" ? `${target}$1` : target + ); + case "regex": + return requestPath.replace(new RegExp(matchRegex), target); + case "stripPrefix": { + if (pathMatchType === "prefix") { + const stripped = requestPath.startsWith(matched) + ? requestPath.slice(matched.length) + : requestPath; + const prefix = target && target !== "/" ? target : ""; + return `${prefix}${stripped}` || "/"; + } + return requestPath.replace(new RegExp(matchRegex), target || "/"); + } + default: + return requestPath; + } +} + function escapeRegex(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/server/lib/traefik/redirect.ts b/server/lib/traefik/redirect.ts new file mode 100644 index 000000000..05f6eae00 --- /dev/null +++ b/server/lib/traefik/redirect.ts @@ -0,0 +1,123 @@ +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { + buildHostRule, + appendPathMatch, + computeRedirectPriority +} from "@server/lib/traefik/rule"; + +export type RedirectRouteRow = { + redirectId: number; + /** Host the redirect listens on (resource fullDomain or subdomain.baseDomain). */ + fullDomain: string; + hasSubdomain: boolean; + attachedTo: "resource" | "domain"; + enabled: boolean; + name: string; + wildcard: boolean | null; + ssl: boolean; + matchPath: string | null; + pathMatchType: string; + priority: number | null; + domainCertResolver?: string | null; + preferWildcardCert?: boolean | null; +}; + +// Traefik requires a service on every router, but a redirect router's +// middleware chain always terminates the request with a 30x, so the service +// is never reached. noop@internal answers 418 if it ever is - treat that as +// a bug in the middleware chain, not something to route around. +const NOOP_SERVICE = "noop@internal"; + +export function buildRedirectConfig(params: { + config_output: any; + redirects: RedirectRouteRow[]; + badgerMiddlewareName: string; + redirectHttpsMiddlewareName: string; + resolveTls: (row: RedirectRouteRow) => any | null; +}): void { + const { + config_output, + redirects, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls + } = params; + + if (redirects.length === 0) { + return; + } + + const httpEntrypoint = config.getRawConfig().traefik.http_entrypoint; + const httpsEntrypoint = config.getRawConfig().traefik.https_entrypoint; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [badgerMiddlewareName, ...additionalMiddlewares]; + + for (const redirect of redirects) { + const routerName = `${redirect.redirectId}-redirect-${redirect.name}-router`; + + if (!redirect.enabled) { + logger.debug( + `Redirect ${redirect.name} is disabled, skipping Traefik config` + ); + continue; + } + + let tls: any = {}; + if (redirect.ssl) { + tls = resolveTls(redirect); + if (tls === null) { + continue; + } + } + + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + + if (redirect.matchPath && redirect.pathMatchType === "regex") { + try { + new RegExp(redirect.matchPath); + } catch { + logger.debug( + `Invalid regex pattern in redirect ${redirect.redirectId} match path: ${redirect.matchPath}` + ); + continue; + } + } + + const rule = appendPathMatch( + buildHostRule(redirect.fullDomain, redirect.wildcard), + redirect.matchPath, + redirect.pathMatchType + ); + + const priority = computeRedirectPriority( + redirect.priority, + redirect.matchPath, + redirect.pathMatchType + ); + + // 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`] = { + entryPoints: [httpEntrypoint], + middlewares: [redirectHttpsMiddlewareName], + service: NOOP_SERVICE, + rule, + priority + }; + } + + config_output.http.routers[routerName] = { + entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint], + middlewares: routerMiddlewares, + service: NOOP_SERVICE, + rule, + priority, + ...(redirect.ssl ? { tls } : {}) + }; + } +} diff --git a/server/lib/traefik/rule.test.ts b/server/lib/traefik/rule.test.ts new file mode 100644 index 000000000..47e21f85a --- /dev/null +++ b/server/lib/traefik/rule.test.ts @@ -0,0 +1,138 @@ +import { assertEquals } from "../../../test/assert"; +import { + computeRedirectPriority, + computeRoutePriority, + matchesPath +} from "./rule"; + +function runTests() { + console.log("Running matchesPath tests..."); + + // No path config matches everything + assertEquals(matchesPath("/anything", null, null), true, "null path"); + assertEquals(matchesPath("/anything", "/a", null), true, "null type"); + assertEquals( + matchesPath("/anything", null, "prefix"), + true, + "null path w/ type" + ); + + // exact + assertEquals(matchesPath("/api", "/api", "exact"), true, "exact match"); + assertEquals( + matchesPath("/api/", "/api", "exact"), + false, + "exact trailing slash" + ); + assertEquals(matchesPath("/api/x", "/api", "exact"), false, "exact child"); + assertEquals( + matchesPath("/api", "api", "exact"), + true, + "exact leading slash added" + ); + + // prefix (segment-aware, like Traefik v3 PathPrefix) + assertEquals( + matchesPath("/products", "/products", "prefix"), + true, + "prefix itself" + ); + assertEquals( + matchesPath("/products/", "/products", "prefix"), + true, + "prefix slash" + ); + assertEquals( + matchesPath("/products/shoes", "/products", "prefix"), + true, + "prefix child" + ); + assertEquals( + matchesPath("/productsforsale", "/products", "prefix"), + false, + "prefix partial segment" + ); + assertEquals( + matchesPath("/products/shoes", "/products/", "prefix"), + true, + "prefix with trailing slash" + ); + assertEquals( + matchesPath("/products", "/products/", "prefix"), + false, + "trailing-slash prefix vs bare" + ); + assertEquals( + matchesPath("/other", "/products", "prefix"), + false, + "prefix miss" + ); + + // regex (unanchored, like PathRegexp) + assertEquals( + matchesPath("/api/v1/x", "^/api/.*", "regex"), + true, + "regex anchored" + ); + assertEquals( + matchesPath("/x/api/v1", "/api/", "regex"), + true, + "regex unanchored" + ); + assertEquals(matchesPath("/foo", "^/api", "regex"), false, "regex miss"); + assertEquals( + matchesPath("/foo", "(", "regex"), + false, + "invalid regex never matches" + ); + + console.log("All matchesPath tests passed!"); + + console.log("Running priority tests..."); + + // Resource routers: explicit override, else derived from path specificity + assertEquals(computeRoutePriority(null, null, null), 100, "default"); + assertEquals(computeRoutePriority(100, null, null), 100, "100 is auto"); + assertEquals(computeRoutePriority(500, "/a", "exact"), 500, "explicit"); + assertEquals(computeRoutePriority(null, "/a", "exact"), 115, "exact"); + assertEquals(computeRoutePriority(null, "/a", "prefix"), 113, "prefix"); + assertEquals(computeRoutePriority(null, "/a", "regex"), 112, "regex"); + assertEquals(computeRoutePriority(null, "/", "prefix"), 1, "catch-all"); + + // Redirect routers always land above any resource router (max 1000) + assertEquals( + computeRedirectPriority(null, null, null), + 1100, + "redirect default" + ); + assertEquals( + computeRedirectPriority(null, "/", "prefix"), + 1001, + "redirect catch-all" + ); + assertEquals( + computeRedirectPriority(1, null, null), + 1001, + "redirect lowest explicit" + ); + assertEquals( + computeRedirectPriority(1000, null, null), + 2000, + "redirect highest explicit" + ); + assertEquals( + computeRedirectPriority(1, "/", "prefix") > + computeRoutePriority(1000, "/a", "exact"), + true, + "weakest redirect beats strongest resource" + ); + + console.log("All priority tests passed!"); +} + +try { + runTests(); +} catch (error) { + console.error("Test failed:", error); + process.exit(1); +} diff --git a/server/lib/traefik/rule.ts b/server/lib/traefik/rule.ts index 39096eacd..e304e9ef9 100644 --- a/server/lib/traefik/rule.ts +++ b/server/lib/traefik/rule.ts @@ -36,6 +36,48 @@ export function appendPathMatch( return rule; } +/** + * Server-side equivalent of the clause appendPathMatch emits, so badger can + * tell whether a request would have matched a given path config. Mirrors + * Traefik v3 semantics: Path is exact, PathPrefix is segment-aware + * (`/products` matches `/products/shoes` but not `/productsforsale`), and + * PathRegexp is an unanchored regex test. + */ +export function matchesPath( + requestPath: string, + path: string | null | undefined, + pathMatchType: string | null | undefined +): boolean { + if (!path || !pathMatchType) return true; + + if (pathMatchType === "regex") { + try { + return new RegExp(path).test(requestPath); + } catch { + return false; + } + } + + let p = path; + if (!p.startsWith("/")) { + p = `/${p}`; + } + + if (pathMatchType === "exact") { + return requestPath === p; + } else if (pathMatchType === "prefix") { + if (!requestPath.startsWith(p)) { + return false; + } + if (p.endsWith("/")) { + return true; + } + const rest = requestPath.slice(p.length); + return rest === "" || rest.startsWith("/"); + } + return true; +} + // Compute the router priority for a resource, favoring an explicit override // and otherwise deriving it from the path match specificity. export function computeRoutePriority( @@ -63,3 +105,25 @@ export function computeRoutePriority( } return p; } + +// Redirects must always be evaluated before resource routers on the same +// host. Target and redirect priorities are both capped at 1000, so lifting +// every redirect by this offset puts them in a band (1001-2000) no resource +// router can reach, while explicit priorities still order redirects among +// themselves. +export const REDIRECT_PRIORITY_OFFSET = 1000; + +/** + * Compute the router priority for a redirect: the same derivation as a + * resource router, shifted into the redirect band. + */ +export function computeRedirectPriority( + priority: number | null | undefined, + path: string | null | undefined, + pathMatchType: string | null | undefined +): number { + return ( + computeRoutePriority(priority, path, pathMatchType) + + REDIRECT_PRIORITY_OFFSET + ); +} diff --git a/server/lib/traefik/types.ts b/server/lib/traefik/types.ts index ab19ee653..cec77aad6 100644 --- a/server/lib/traefik/types.ts +++ b/server/lib/traefik/types.ts @@ -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. -export type TargetWithSite = Target & { +export type TargetWithSite = { resourceId: number; targetId: number; ip: string | null; @@ -19,3 +19,48 @@ export type TargetWithSite = Target & { 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" + | "loadBalancingStrategy" + | "mtlsEnabled" + | "tlsServerName" + | "setHostHeader" + | "enableProxy" + | "requestHeaders" + | "responseHeaders" + | "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[]; + }; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index c516ede09..45ca71f7a 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -22,3 +22,4 @@ export * from "./verifyApiKeyAiBudgetAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess"; export * from "./verifyVirtualApiKeyAccess"; +export * from "./verifyApiKeyRedirectAccess"; diff --git a/server/middlewares/integration/verifyApiKeyRedirectAccess.ts b/server/middlewares/integration/verifyApiKeyRedirectAccess.ts new file mode 100644 index 000000000..373310da6 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyRedirectAccess.ts @@ -0,0 +1,116 @@ +import { Request, Response, NextFunction } from "express"; +import { Redirect, apiKeyOrg, db, redirects } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyRedirectAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const redirectIdRaw = getFirstString(req.params.redirectId); + const niceId = getFirstString(req.params.niceId); + const orgIdParam = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgIdParam) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + let redirect: Redirect | undefined; + + if (niceId) { + const [redirectRes] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.niceId, niceId), + eq(redirects.orgId, orgIdParam) + ) + ) + .limit(1); + redirect = redirectRes; + } else { + const redirectId = Number.parseInt(redirectIdRaw ?? "", 10); + + if (Number.isNaN(redirectId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid redirect ID") + ); + } + + const [redirectRes] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgIdParam) + ) + ) + .limit(1); + redirect = redirectRes; + } + + if (!redirect) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectIdRaw || niceId} not found` + ) + ); + } + + if (apiKey.isRoot) { + req.redirect = redirect; + return next(); + } + + const orgId = redirect.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.redirect = redirect; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying redirect access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index a9e36948c..94e2079d0 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -33,6 +33,7 @@ export enum OpenAPITags { AiModel = "AI Model", AiBudget = "AI Budget", VirtualApiKey = "Virtual API Key", + Redirect = "Redirect", Label = "Label" } diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index dffa009ad..77743e6cd 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -11,6 +11,10 @@ * This file is not licensed under the AGPLv3. */ +import { regionalCache } from "#private/lib/cache"; +import privateConfig from "#private/lib/config"; +import license from "#private/license/license"; +import { build } from "@server/build"; import { certificates, db, @@ -19,74 +23,72 @@ import { exitNodes, loginPage, resourceMtlsCertificates, - SiteResource, - targetHealthCheck -} from "@server/db"; -import { - and, - eq, - inArray, - or, - isNull, - ne, - isNotNull, - desc, - sql -} from "drizzle-orm"; -import logger from "@server/logger"; -import config from "@server/lib/config"; -import { - orgs, + redirects, resources, - sites, siteNetworks, + SiteResource, siteResources, + sites, + targetHealthCheck, targets } from "@server/db"; -import { - sanitize, - encodePath, - validatePathRewriteConfig -} from "@server/lib/traefik/utils"; -import privateConfig from "#private/lib/config"; -import { applyPathRewriteMiddleware } from "@server/lib/traefik/middleware"; import { CertificateResult, getValidCertificatesForDomains } from "@server/lib/certificates"; -import { build } from "@server/build"; -import license from "#private/license/license"; -import { regionalCache } from "#private/lib/cache"; -import { TargetWithSite } from "@server/lib/traefik/types"; -import { buildWildcardTls } from "@server/lib/traefik/certResolver"; +import config from "@server/lib/config"; import { - buildHostRule, - appendPathMatch, - computeRoutePriority -} from "@server/lib/traefik/rule"; + AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME, + AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE, + AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE, + buildAiGatewayClientIpMiddleware, + buildAiGatewayHostHeaderMiddleware, + buildAiGatewayRouterAndService, + buildAiGatewayTrustMiddlewares, + getAiGatewayHost +} from "@server/lib/traefik/aiGatewayMiddlewares"; +import { + buildBrowserGatewayConfig, + buildBrowserGatewayResourcesMap +} from "@server/lib/traefik/browserGateway"; +import { buildWildcardTls } from "@server/lib/traefik/certResolver"; +import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware"; import { buildHttpLoadBalancerServers, buildLoadBalancingStrategy, buildStickySessionCookie, - buildTcpUdpLoadBalancerServers, - buildStickySessionIp + buildStickySessionIp, + buildTcpUdpLoadBalancerServers } from "@server/lib/traefik/loadBalancer"; -import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware"; +import { applyPathRewriteMiddleware } from "@server/lib/traefik/middleware"; import { - AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE, - AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE, - AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME, - getAiGatewayHost, - buildAiGatewayTrustMiddlewares, - buildAiGatewayClientIpMiddleware, - buildAiGatewayHostHeaderMiddleware, - buildAiGatewayRouterAndService -} from "@server/lib/traefik/aiGatewayMiddlewares"; + buildRedirectConfig, + RedirectRouteRow +} from "@server/lib/traefik/redirect"; import { - buildBrowserGatewayResourcesMap, - buildBrowserGatewayConfig -} from "@server/lib/traefik/browserGateway"; + appendPathMatch, + buildHostRule, + computeRoutePriority +} from "@server/lib/traefik/rule"; import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias"; +import { ResourceWithTargets } from "@server/lib/traefik/types"; +import { + encodePath, + sanitize, + validatePathRewriteConfig +} from "@server/lib/traefik/utils"; +import logger from "@server/logger"; +import { + and, + desc, + eq, + inArray, + isNotNull, + isNull, + not, + or, + sql +} from "drizzle-orm"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; @@ -220,7 +222,7 @@ export async function getTraefikConfig( .orderBy(desc(targets.priority), targets.targetId); // stable ordering // Group by resource and include targets with their unique site data - const resourcesMap = new Map(); + const resourcesMap = new Map(); for (const row of resourcesWithTargetsAndSites) { if (!["http", "tcp", "udp"].includes(row.mode)) { @@ -248,7 +250,7 @@ export async function getTraefikConfig( .filter(Boolean) .join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); - const key = sanitize(mapKey); + const key = sanitize(mapKey) ?? ""; if (!resourcesMap.has(mapKey)) { const validation = validatePathRewriteConfig( @@ -306,7 +308,7 @@ export async function getTraefikConfig( } // Add target with its associated site data - resourcesMap.get(mapKey).targets.push({ + resourcesMap.get(mapKey)!.targets.push({ resourceId: row.resourceId, targetId: row.targetId, ip: row.ip, @@ -433,6 +435,95 @@ export async function getTraefikConfig( ) ); + // Redirects have no targets/sites, so like inference resources they are + // queried separately and emitted on every exit node. A redirect listens + // either on a resource's fullDomain or on subdomain.baseDomain of a + // domain; the domain join resolves to whichever one applies. + const redirectRows = await db + .select({ + name: redirects.name, + enabled: redirects.enabled, + redirectId: redirects.redirectId, + subdomain: redirects.subdomain, + matchPath: redirects.matchPath, + pathMatchType: redirects.pathMatchType, + priority: redirects.priority, + ssl: redirects.ssl, + // Resource (when attached to one) + resourceId: resources.resourceId, + resourceFullDomain: resources.fullDomain, + resourceSubdomain: resources.subdomain, + resourceSsl: resources.ssl, + resourceWildcard: resources.wildcard, + // Domain (the redirect's own, or the resource's) + baseDomain: domains.baseDomain, + domainCertResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert, + domainNamespaceId: domainNamespaces.domainNamespaceId + }) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin( + domains, + eq( + domains.domainId, + sql`coalesce(${redirects.domainId}, ${resources.domainId})` + ) + ) + .leftJoin( + domainNamespaces, + eq(domainNamespaces.domainId, domains.domainId) + ) + .where( + and( + eq(redirects.enabled, true), + or( + isNull(redirects.resourceId), + and( + eq(resources.enabled, true), + not(isNull(resources.domainId)) + ) + ) + ) + ) + .orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering + + const redirectRoutes: RedirectRouteRow[] = []; + for (const row of redirectRows) { + if (filterOutNamespaceDomains && row.domainNamespaceId) { + continue; + } + + const attachedToResource = row.resourceId !== null; + const fullDomain = attachedToResource + ? row.resourceFullDomain + : [row.subdomain, row.baseDomain].filter(Boolean).join("."); + if (!fullDomain) { + logger.debug( + `Redirect ${row.redirectId} has no host to listen on, skipping Traefik config` + ); + continue; + } + + redirectRoutes.push({ + enabled: row.enabled, + name: sanitize(row.name) || "", + redirectId: row.redirectId, + fullDomain, + hasSubdomain: attachedToResource + ? !!row.resourceSubdomain + : !!row.subdomain, + wildcard: row.resourceWildcard, + ssl: attachedToResource ? !!row.resourceSsl : row.ssl, + attachedTo: attachedToResource ? "resource" : "domain", + matchPath: row.matchPath, + pathMatchType: row.pathMatchType, + priority: row.priority, + domainCertResolver: row.domainCertResolver, + preferWildcardCert: row.preferWildcardCert + }); + } + // Pangolin-managed DNS-01/ACME cert mode requires either a tier1 // license (self-hosted) or a saas build - otherwise fall back to // Traefik's own cert resolvers (buildWildcardTls) throughout. @@ -472,6 +563,12 @@ export async function getTraefikConfig( domains.add(sr.fullDomain); } } + // Include redirect hosts + for (const redirect of redirectRoutes) { + if (redirect.ssl) { + domains.add(redirect.fullDomain); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -506,7 +603,7 @@ export async function getTraefikConfig( // get the key and the resource for (const [, resource] of resourcesMap.entries()) { - const targets = resource.targets as TargetWithSite[]; + const targets = resource.targets; const key = resource.key; const routerName = `${key}-${resource.name}-router`; @@ -685,7 +782,7 @@ export async function getTraefikConfig( if (!target.site.online) return false; - if (target.health == "unhealthy") return false; + if (target.health === "unhealthy") return false; return true; }); @@ -875,6 +972,34 @@ export async function getTraefikConfig( } } + buildRedirectConfig({ + config_output, + redirects: redirectRoutes, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls: (redirect) => { + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + return buildWildcardTls({ + fullDomain: redirect.fullDomain, + hasSubdomain: redirect.hasSubdomain, + domainCertResolver: redirect.domainCertResolver, + preferWildcardCert: + redirect.preferWildcardCert || redirect.wildcard + }); + } + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === redirect.fullDomain + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for redirect domain: ${redirect.fullDomain}` + ); + return null; + } + return {}; + } + }); + if (browserGatewayUiUrl) { buildBrowserGatewayConfig({ config_output, diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index caad19732..7000f5aa3 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -96,12 +96,20 @@ import semver from "semver"; import { maxmindAsnLookup } from "@server/db/maxmindAsn"; import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy"; import { sanitizeString } from "@server/lib/sanitize"; +import { + getRedirectsByHost, + type RedirectByHost +} from "@server/db/queries/verifySessionQueries"; // Zod schemas for request validation const getResourceByDomainParamsSchema = z.strictObject({ domain: z.string().min(1, "Domain is required") }); +const getRedirectsByHostParamsSchema = z.strictObject({ + host: z.string().min(1, "Host is required") +}); + const getUserSessionParamsSchema = z.strictObject({ userSessionId: z.string().min(1, "User session ID is required") }); @@ -818,6 +826,44 @@ hybridRouter.get( } ); +hybridRouter.get( + "/redirects/host/:host", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getRedirectsByHostParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { host } = parsedParams.data; + const redirects = await getRedirectsByHost(host); + + return response(res, { + data: redirects, + success: true, + error: false, + message: "Redirects retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get redirects by host" + ) + ); + } + } +); + const getOrgLoginPageParamsSchema = z.strictObject({ orgId: z.string().min(1) }); diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 3fb97dce5..caf2d7823 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -20,6 +20,7 @@ Reasons: 107 - Valid SSO 108 - Connected Client 109 - Valid Virtual API Key +110 - Allowed Redirect 201 - Resource Not Found 202 - Resource Blocked diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 4b6818d0e..e24e64729 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -10,6 +10,8 @@ import { verifyVirtualApiKey } from "@server/auth/verifyVirtualApiKey"; import { + type RedirectByHost, + getRedirectsByHost, getResourceByDomain, getResourceRules, getRoleResourceAccess, @@ -40,6 +42,8 @@ import { import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { isPathAllowed } from "@server/lib/pathMatch"; +import { matchesPath } from "@server/lib/traefik/rule"; +import { rewriteRequestPath } from "@server/lib/traefik/middleware"; import { parseHttpMethodList } from "@server/lib/validators"; import { response } from "@server/lib/response"; import logger from "@server/logger"; @@ -68,6 +72,7 @@ import { APP_VERSION } from "@server/lib/consts"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { eq } from "drizzle-orm"; +import type ResponseT from "@server/types/MessageResponse"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -109,6 +114,9 @@ export type VerifyUserResponse = { valid: boolean; headerAuthChallenged?: boolean; redirectUrl?: string; + // Set alongside redirectUrl when the redirect is a configured Redirect + // rather than a login bounce, so badger can answer 307 instead of 302. + redirectPermanent?: boolean; userData?: BasicUserData; pangolinVersion?: string; dontStripSession?: boolean; @@ -193,6 +201,30 @@ export async function verifyResourceSession( cleanHost = cleanHost.slice(0, -1 * matched.length); } + // Redirects always win: they are routed ahead of resources in + // Traefik and never require auth, even when attached to a resource, + // so let a matching one through to the redirect middleware before + // any resource lookup. + const redirect = await findRedirect(cleanHost, path); + if (redirect) { + const redirectUrl = buildRedirectUrl(redirect, parsedBody.data); + logger.debug( + `Redirecting ${cleanHost}${path} to ${redirectUrl} (redirect ${redirect.redirectId})` + ); + + logRequestAudit( + { + action: true, + reason: 110, // redirected + orgId: redirect.orgId, + location: ipCC + }, + parsedBody.data + ); + + return redirected(res, redirectUrl, redirect.permanent); + } + const resourceCacheKey = `resource:${cleanHost}`; let resourceData: | { @@ -200,9 +232,7 @@ export async function verifyResourceSession( pincode: ResourcePincode | ResourcePolicyPincode | null; password: ResourcePassword | ResourcePolicyPassword | null; headerAuth: - | ResourceHeaderAuth - | ResourcePolicyHeaderAuth - | null; + ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; applyRules: boolean | null; sso: boolean | null; @@ -1019,6 +1049,71 @@ function extractResourceSessionToken( return latest.token; } +async function findRedirect( + host: string, + path: string +): Promise { + const cacheKey = `redirects:${host}`; + let candidates: RedirectByHost[] | undefined = localCache.get(cacheKey); + if (!candidates) { + candidates = await getRedirectsByHost(host); + localCache.set(cacheKey, candidates, 5); + } + + // Candidates come back highest priority first, matching the order + // Traefik evaluates the routers in. + return ( + candidates.find((r) => + matchesPath(path, r.matchPath, r.pathMatchType) + ) ?? null + ); +} + +/** + * 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( + redirect: RedirectByHost, + request: VerifyResourceSessionSchema +): string { + const newPath = rewriteRequestPath( + request.path, + redirect.matchPath, + redirect.pathMatchType, + redirect.rewritePath, + redirect.rewritePathType + ); + + let search = ""; + try { + search = new URL(request.originalRequestURL).search; + } catch { + // originalRequestURL is validated as a URL, so this is only defensive + } + + return `${redirect.destinationHost}${newPath}${search}`; +} + +// Like a notAllowed login bounce, but the destination is the configured +// redirect target rather than the auth page. +function redirected(res: Response, redirectUrl: string, permanent: boolean) { + const data = { + data: { + valid: true, + redirectUrl, + redirectPermanent: permanent, + pangolinVersion: APP_VERSION + }, + success: true, + error: false, + message: "Redirected", + status: HttpCode.OK + } satisfies ResponseT; + return response(res, data); +} + async function notAllowed( res: Response, redirectPath?: string, diff --git a/server/routers/external.ts b/server/routers/external.ts index 37bb8e98d..866b110f2 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -62,6 +62,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; +import * as redirect from "@server/routers/redirect"; import * as aiBudget from "@server/routers/aiBudget"; import * as virtualApiKey from "@server/routers/virtualApiKey"; import * as certificates from "@server/routers/certificates"; @@ -1670,6 +1671,50 @@ authenticated.delete( aiProvider.deleteAiProvider ); +authenticated.put( + "/org/:orgId/redirect", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createRedirect), + logActionAudit(ActionsEnum.createRedirect), + redirect.createRedirect +); + +authenticated.get( + "/org/:orgId/redirects", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listRedirects), + redirect.listRedirects +); + +authenticated.get( + "/org/:orgId/redirects/:redirectId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); +authenticated.get( + "/org/:orgId/redirect/:niceId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); + +authenticated.post( + "/org/:orgId/redirects/:redirectId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateRedirect), + logActionAudit(ActionsEnum.updateRedirect), + redirect.updateRedirect +); + +authenticated.delete( + "/org/:orgId/redirects/:redirectId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteRedirect), + logActionAudit(ActionsEnum.deleteRedirect), + redirect.deleteRedirect +); + authenticated.put( "/ai-provider/:providerId/model", verifyAiProviderAccess, diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index a3f1dc112..e9b77b561 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -1,46 +1,45 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db, Org, primaryDb } from "@server/db"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { - idp, - idpOidcConfig, - idpOrg, - orgs, - Role, - roles, - userOrgRoles, - userOrgs, - users -} from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; -import * as arctic from "arctic"; -import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; -import jmespath from "jmespath"; -import jsonwebtoken from "jsonwebtoken"; -import config from "@server/lib/config"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; import { createSession, generateId, generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; -import { decrypt } from "@server/lib/crypto"; -import { UserType } from "@server/types/UserTypes"; -import { LimitId } from "@server/lib/billing"; -import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; +import { + db, + idp, + idpOidcConfig, + idpOrg, + Org, + orgs, + roles, + userOrgRoles, + userOrgs, + users +} from "@server/db"; +import { LimitId } from "@server/lib/billing"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { usageService } from "@server/lib/billing/usageService"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { pushClientIdentitiesForUser } from "@server/lib/clientIdentities"; -import { isSubscribed } from "#dynamic/lib/isSubscribed"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import response from "@server/lib/response"; import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg"; -import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { UserType } from "@server/types/UserTypes"; +import * as arctic from "arctic"; +import { and, eq, inArray } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import jmespath from "jmespath"; +import jsonwebtoken from "jsonwebtoken"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const ensureTrailingSlash = (url: string): string => { return url; diff --git a/server/routers/integration.ts b/server/routers/integration.ts index b78e89eb5..b8f15cf23 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -16,6 +16,7 @@ import * as siteResource from "./siteResource"; import * as aiProvider from "./aiProvider"; import * as aiBudget from "./aiBudget"; import * as virtualApiKey from "./virtualApiKey"; +import * as redirect from "./redirect"; import * as labels from "./labels"; import { verifyApiKey, @@ -39,6 +40,7 @@ import { verifyApiKeyAiModelAccess, verifyApiKeyAiBudgetAccess, verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyRedirectAccess, verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; @@ -1868,6 +1870,55 @@ authenticated.get( aiBudget.listAiBudgetsForVirtualApiKey ); +authenticated.put( + "/org/:orgId/redirect", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createRedirect), + logActionAudit(ActionsEnum.createRedirect), + redirect.createRedirect +); + +authenticated.get( + "/org/:orgId/redirects", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listRedirects), + redirect.listRedirects +); + +authenticated.get( + "/org/:orgId/redirects/:redirectId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); + +authenticated.get( + "/org/:orgId/redirect/:niceId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); + +authenticated.post( + "/org/:orgId/redirects/:redirectId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.updateRedirect), + logActionAudit(ActionsEnum.updateRedirect), + redirect.updateRedirect +); + +authenticated.delete( + "/org/:orgId/redirects/:redirectId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.deleteRedirect), + logActionAudit(ActionsEnum.deleteRedirect), + redirect.deleteRedirect +); + authenticated.get( "/org/:orgId/labels", verifyApiKeyOrgAccess, diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts new file mode 100644 index 000000000..de682e740 --- /dev/null +++ b/server/routers/redirect/createRedirect.ts @@ -0,0 +1,239 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains, redirects, resources } from "@server/db"; +import type { Domain, Redirect, Resource } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { + redirectDestinationHostSchema, + redirectMatchPathSchema, + redirectPathMatchTypeSchema, + redirectRewritePathSchema, + isValidMatchPath, + isAllowedSsl, + redirectPrioritySchema, + redirectRewritePathTypeSchema +} from "@server/routers/redirect/validation"; +import { getUniqueRedirectName } from "@server/db/names"; +import { createCertificate } from "../certificates"; + +export type CreateRedirectResponse = { + redirect: Redirect; +}; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + resourceId: z.number().int().positive().optional().nullable(), + domainId: z.string().nonempty().optional().nullable(), + subdomain: z.string().nonempty().optional().nullable(), + destinationHost: redirectDestinationHostSchema, + pathMatchType: redirectPathMatchTypeSchema.optional(), + matchPath: redirectMatchPathSchema.optional().nullable(), + rewritePath: redirectRewritePathSchema.optional().nullable(), + rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), + priority: redirectPrioritySchema.optional().nullable(), + permanent: z.boolean().optional(), + ssl: z.boolean().optional(), + enabled: z.boolean().optional() + }) + .refine( + (data) => + // stripPrefix removes the matched prefix and needs no replacement + // value; every other rewrite type is meaningless without one. + !data.rewritePathType || + data.rewritePathType === "stripPrefix" || + Boolean(data.rewritePath), + { + message: + "rewritePath is required unless rewritePathType is stripPrefix", + path: ["rewritePath"] + } + ) + .refine((data) => Boolean(data.resourceId) !== Boolean(data.domainId), { + message: "Exactly one of resourceId or domainId must be provided", + path: ["resourceId"] + }) + .refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), { + message: "matchPath must be a valid regular expression", + path: ["matchPath"] + }) + .refine((data) => isAllowedSsl(data.ssl), { + message: "TLS cannot be disabled on this build", + path: ["ssl"] + }); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/redirect", + description: "Create a redirect for an organization.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + name, + resourceId, + domainId, + subdomain, + destinationHost, + pathMatchType, + matchPath, + rewritePath, + rewritePathType, + priority, + permanent, + ssl, + enabled + } = parsedBody.data; + + let resource: Resource | null = null; + if (resourceId) { + const res = await db + .select() + .from(resources) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + resource = res.at(0) ?? null; + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + } + + let domain: Domain | null = null; + if (domainId) { + const res = await db + .select() + .from(domains) + .innerJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ) + .where( + and( + eq(domains.domainId, domainId), + eq(orgDomains.orgId, orgId) + ) + ) + .limit(1); + + domain = res.at(0)?.domains ?? null; + if (!domain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${domainId} not found` + ) + ); + } + } + + const niceId = await getUniqueRedirectName(orgId); + + const [redirect] = await db + .insert(redirects) + .values({ + orgId, + name, + niceId, + resourceId: resourceId ?? null, + domainId: domainId ?? null, + subdomain: subdomain ?? null, + destinationHost, + pathMatchType: pathMatchType ?? "regex", + matchPath: matchPath ?? null, + rewritePath: rewritePath ?? null, + rewritePathType: rewritePathType ?? null, + priority: priority ?? 100, + permanent: permanent ?? false, + // Resource-attached redirects follow the resource's ssl, so + // the column is only meaningful for domain-attached ones. + ssl: resource ? true : (ssl ?? true), + enabled: enabled ?? true + }) + .returning(); + + if (domain) { + const fullDomain = [subdomain ?? null, domain.baseDomain] + .filter(Boolean) + .join("."); + await createCertificate(domain.domainId, fullDomain, db); + } + + return response(res, { + data: { + redirect + }, + success: true, + error: false, + message: "Redirect created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/deleteRedirect.ts b/server/routers/redirect/deleteRedirect.ts new file mode 100644 index 000000000..90dc57584 --- /dev/null +++ b/server/routers/redirect/deleteRedirect.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { redirects, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + redirectId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/redirects/{redirectId}", + description: "Delete a redirect.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, redirectId } = parsedParams.data; + + const [existing] = await db + .select({ redirectId: redirects.redirectId }) + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectId} not found` + ) + ); + } + + await db + .delete(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Redirect deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts new file mode 100644 index 000000000..a2a6c4a4f --- /dev/null +++ b/server/routers/redirect/getRedirect.ts @@ -0,0 +1,188 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { domains, redirects, resources, db } from "@server/db"; +import response from "@server/lib/response"; +import stoi from "@server/lib/stoi"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +export type GetRedirectResponse = { + redirect: { + redirectId: number; + orgId: string; + niceId: string; + name: string; + subdomain: string | null; + destinationHost: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string | null; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; + priority: number | null; + permanent: boolean; + ssl: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + resourceDomainId: string | null; + resourceFullDomain: string | null; + resourceSsl: boolean | null; + resourceWildcard: boolean | null; + domainId: string | null; + baseDomain: string | null; + }; +}; + +const redirectColumns = { + redirectId: redirects.redirectId, + orgId: redirects.orgId, + niceId: redirects.niceId, + name: redirects.name, + subdomain: redirects.subdomain, + destinationHost: redirects.destinationHost, + pathMatchType: redirects.pathMatchType, + matchPath: redirects.matchPath, + rewritePath: redirects.rewritePath, + rewritePathType: redirects.rewritePathType, + priority: redirects.priority, + permanent: redirects.permanent, + ssl: redirects.ssl, + enabled: redirects.enabled, + resourceId: redirects.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId, + resourceFullDomain: resources.fullDomain, + resourceSsl: resources.ssl, + resourceWildcard: resources.wildcard, + domainId: redirects.domainId, + baseDomain: domains.baseDomain, + resourceDomainId: resources.domainId +}; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + redirectId: z + .string() + .optional() + .transform(stoi) + .pipe(z.int().positive().optional()) + .optional(), + niceId: z.string().optional() +}); + +async function query(orgId: string, redirectId?: number, niceId?: string) { + if (redirectId) { + const [res] = await db + .select(redirectColumns) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .limit(1); + return res; + } else if (niceId) { + const [res] = await db + .select(redirectColumns) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) + .where( + and(eq(redirects.niceId, niceId), eq(redirects.orgId, orgId)) + ) + .limit(1); + return res; + } +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/redirects/{redirectId}", + description: "Get a redirect by ID.", + tags: [OpenAPITags.Redirect], + request: { + params: z.object({ + orgId: z.string(), + redirectId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/redirect/{niceId}", + description: + "Get a redirect by orgId and niceId. NiceId is a readable ID for the redirect and unique on a per org basis.", + tags: [OpenAPITags.Redirect], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, redirectId, niceId } = parsedParams.data; + + const redirect = await query(orgId, redirectId, niceId); + + if (!redirect) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectId || niceId} not found` + ) + ); + } + + return response(res, { + data: { + redirect + }, + success: true, + error: false, + message: "Redirect retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/index.ts b/server/routers/redirect/index.ts new file mode 100644 index 000000000..e3ea85f90 --- /dev/null +++ b/server/routers/redirect/index.ts @@ -0,0 +1,5 @@ +export * from "./createRedirect"; +export * from "./listRedirects"; +export * from "./getRedirect"; +export * from "./updateRedirect"; +export * from "./deleteRedirect"; diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts new file mode 100644 index 000000000..1f33af71a --- /dev/null +++ b/server/routers/redirect/listRedirects.ts @@ -0,0 +1,299 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { domains, redirects, resources, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type ListRedirectsResponse = PaginatedResponse<{ + redirects: Array<{ + redirectId: number; + orgId: string; + niceId: string; + name: string; + subdomain: string | null; + destinationHost: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string | null; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; + priority: number | null; + permanent: boolean; + ssl: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + resourceFullDomain: string | null; + resourceSsl: boolean | null; + resourceDomainId: string | null; + domainId: string | null; + baseDomain: string | null; + }>; +}>; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name", "priority"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name", "priority"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + type: z + .enum(["permanent", "temporary"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["permanent", "temporary"], + description: + "Filter redirects by type. `permanent` is a 308 redirect and `temporary` is a 307 redirect." + }), + enabled: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter redirects based on enabled status" + }), + resourceId: z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined) + .openapi({ + type: "integer", + description: "Filter redirects attached to this resource" + }), + domainId: z.string().nonempty().optional().catch(undefined).openapi({ + type: "string", + description: "Filter redirects attached to this domain" + }) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/redirects", + description: "List redirects for an organization.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listRedirects( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { + pageSize, + page, + query, + sort_by, + order, + type, + enabled, + resourceId, + domainId + } = parsedQuery.data; + const conditions = [eq(redirects.orgId, orgId)]; + + if (type === "permanent") { + conditions.push(eq(redirects.permanent, true)); + } else if (type === "temporary") { + conditions.push(eq(redirects.permanent, false)); + } + + if (typeof enabled !== "undefined") { + conditions.push(eq(redirects.enabled, enabled)); + } + + if (typeof resourceId !== "undefined") { + conditions.push(eq(redirects.resourceId, resourceId)); + } + + if (typeof domainId !== "undefined") { + conditions.push(eq(redirects.domainId, domainId)); + } + + if (query) { + const term = "%" + query.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${redirects.name})`, term), + like(sql`LOWER(${redirects.matchPath})`, term), + like(sql`LOWER(${redirects.destinationHost})`, term) + )! + ); + } + + const baseQuery = db + .select({ + redirectId: redirects.redirectId, + orgId: redirects.orgId, + niceId: redirects.niceId, + name: redirects.name, + subdomain: redirects.subdomain, + destinationHost: redirects.destinationHost, + pathMatchType: redirects.pathMatchType, + matchPath: redirects.matchPath, + rewritePath: redirects.rewritePath, + rewritePathType: redirects.rewritePathType, + priority: redirects.priority, + permanent: redirects.permanent, + ssl: redirects.ssl, + enabled: redirects.enabled, + resourceId: redirects.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId, + resourceFullDomain: resources.fullDomain, + resourceSsl: resources.ssl, + resourceDomainId: resources.domainId, + domainId: redirects.domainId, + baseDomain: domains.baseDomain + }) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) + .where(and(...conditions)); + + const countQuery = db.$count( + db + .select() + .from(redirects) + .where(and(...conditions)) + .as("filtered_redirects") + ); + + // Null priority is shown as 100, so sort on that same value. + const prioritySort = sql`COALESCE(${redirects.priority}, 100)`; + const orderBy = + sort_by === "name" + ? order === "asc" + ? [asc(redirects.name), desc(redirects.redirectId)] + : [desc(redirects.name), desc(redirects.redirectId)] + : sort_by === "priority" + ? order === "asc" + ? [asc(prioritySort), desc(redirects.redirectId)] + : [desc(prioritySort), desc(redirects.redirectId)] + : [desc(prioritySort), desc(redirects.redirectId)]; + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(...orderBy) + ]); + + return response(res, { + data: { + redirects: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "Redirects retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts new file mode 100644 index 000000000..31038a74c --- /dev/null +++ b/server/routers/redirect/updateRedirect.ts @@ -0,0 +1,325 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains, redirects, resources } from "@server/db"; +import type { Redirect } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, ne } from "drizzle-orm"; +import { + redirectNiceIdSchema, + redirectDestinationHostSchema, + redirectMatchPathSchema, + redirectPathMatchTypeSchema, + redirectRewritePathSchema, + redirectRewritePathTypeSchema, + redirectPrioritySchema, + isValidMatchPath, + isAllowedSsl +} from "@server/routers/redirect/validation"; +import { createCertificate } from "../certificates"; + +export type UpdateRedirectResponse = { + redirect: Redirect; +}; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + redirectId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + niceId: redirectNiceIdSchema.optional(), + resourceId: z.number().int().positive().optional().nullable(), + domainId: z.string().nonempty().optional().nullable(), + subdomain: z.string().nonempty().optional().nullable(), + destinationHost: redirectDestinationHostSchema.optional(), + pathMatchType: redirectPathMatchTypeSchema.optional(), + matchPath: redirectMatchPathSchema.optional().nullable(), + rewritePath: redirectRewritePathSchema.optional().nullable(), + rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), + priority: redirectPrioritySchema.optional(), + permanent: z.boolean().optional(), + ssl: z.boolean().optional(), + enabled: z.boolean().optional() +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/redirects/{redirectId}", + description: "Update a redirect.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, redirectId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectId} not found` + ) + ); + } + + const effectiveResourceId = + body.resourceId !== undefined + ? body.resourceId + : existing.resourceId; + const effectiveDomainId = + body.domainId !== undefined ? body.domainId : existing.domainId; + + if (Boolean(effectiveResourceId) === Boolean(effectiveDomainId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exactly one of resourceId or domainId must be provided" + ) + ); + } + + if (body.resourceId) { + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + eq(resources.resourceId, body.resourceId), + eq(resources.orgId, existing.orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${body.resourceId} not found` + ) + ); + } + } + + let domain: { domainId: string; baseDomain: string } | null = null; + if (effectiveDomainId) { + const [d] = await db + .select({ + domainId: domains.domainId, + baseDomain: domains.baseDomain + }) + .from(domains) + .innerJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ) + .where( + and( + eq(domains.domainId, effectiveDomainId), + eq(orgDomains.orgId, existing.orgId) + ) + ) + .limit(1); + + domain = d ?? null; + if (!domain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${effectiveDomainId} not found` + ) + ); + } + } + + if (body.niceId) { + const [existingNiceId] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.niceId, body.niceId), + eq(redirects.orgId, existing.orgId), + ne(redirects.redirectId, existing.redirectId) // exclude the current redirect from the search + ) + ) + .limit(1); + + if (existingNiceId) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A redirect with niceId "${body.niceId}" already exists` + ) + ); + } + } + + 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" + ) + ); + } + + if (!isAllowedSsl(body.ssl)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "TLS cannot be disabled on this build" + ) + ); + } + + const updateData: Partial = {}; + + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.niceId !== undefined) { + updateData.niceId = body.niceId; + } + if (body.resourceId !== undefined) { + updateData.resourceId = body.resourceId; + } + if (body.domainId !== undefined) { + updateData.domainId = body.domainId; + } + if (body.subdomain !== undefined) { + updateData.subdomain = body.subdomain; + } + if (body.destinationHost !== undefined) { + updateData.destinationHost = body.destinationHost; + } + if (body.pathMatchType !== undefined) { + updateData.pathMatchType = body.pathMatchType; + } + if (body.matchPath !== undefined) { + updateData.matchPath = body.matchPath; + } + if (body.rewritePath !== undefined) { + updateData.rewritePath = body.rewritePath; + } + if (body.rewritePathType !== undefined) { + updateData.rewritePathType = body.rewritePathType; + } + if (body.priority !== undefined) { + updateData.priority = body.priority; + } + if (body.permanent !== undefined) { + updateData.permanent = body.permanent; + } + if (effectiveResourceId) { + // Resource-attached redirects follow the resource's ssl; reset + // the column so a later move back to a domain starts from TLS on. + updateData.ssl = true; + } else if (body.ssl !== undefined) { + updateData.ssl = body.ssl; + } + if (body.enabled !== undefined) { + updateData.enabled = body.enabled; + } + + const [redirect] = await db + .update(redirects) + .set(updateData) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .returning(); + + if (domain) { + const effectiveSubdomain = + body.subdomain !== undefined + ? body.subdomain + : existing.subdomain; + const fullDomain = [effectiveSubdomain ?? null, domain.baseDomain] + .filter(Boolean) + .join("."); + await createCertificate(domain.domainId, fullDomain, db); + } + + return response(res, { + data: { + redirect + }, + success: true, + error: false, + message: "Redirect updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts new file mode 100644 index 000000000..fa304fd9e --- /dev/null +++ b/server/routers/redirect/validation.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import { isValidDomain } from "@server/lib/validators"; +import { build } from "@server/build"; + +export const redirectNiceIdSchema = z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ); + +export const redirectPathMatchTypeSchema = z.enum(["exact", "prefix", "regex"]); + +export const redirectRewritePathTypeSchema = z.enum([ + "exact", + "prefix", + "regex", + "stripPrefix" +]); + +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(); + +// Same range as target priorities; 100 means "let the system order it". +export const redirectPrioritySchema = z.int().min(1).max(1000); + +/** + * 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 { + if (URL.canParse(value)) { + const url = new URL(value); + /** URL origin contains the destination with no path or query */ + return value === url.origin; + } + return false; +} + +export const redirectDestinationHostSchema = z + .string() + .nonempty() + .refine(isValidDestinationHost, { + message: + "Invalid destination, expected scheme://host such as https://example.com" + }); + +/** + * The cloud only serves HTTPS, so a domain-attached redirect may not opt out + * of TLS there. Resource-attached redirects inherit the resource's ssl + * setting and never carry their own. + */ +export function isAllowedSsl(ssl: boolean | undefined): boolean { + return build !== "saas" || ssl !== false; +} diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 9643aa0b1..11112c30b 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -29,8 +29,8 @@ export async function traefikConfigProvider( const traefikConfig = await getTraefikConfig( currentExitNodeId, config.getRawConfig().traefik.site_types, - build == "oss", // filter out the namespace domains in open source - build != "oss", // generate the login pages on the cloud and and enterprise, + build === "oss", // filter out the namespace domains in open source + build !== "oss", // generate the login pages on the cloud and and enterprise, config.getRawConfig().traefik.allow_raw_resources, pangolinUIUrl, pangolinUIUrl, @@ -75,8 +75,7 @@ export async function traefikConfigProvider( .resource_session_request_param, remoteUserIdHeader: - config.getRawConfig().server.remote_headers - .user_id, + config.getRawConfig().server.remote_headers.user_id, remoteVirtualApiKeyIdHeader: config.getRawConfig().server.remote_headers diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 378cdcad5..9ba33126d 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -281,6 +281,7 @@ export default function GeneralPage() { // 107 - Valid SSO // 108 - Connected Client // 109 - Valid Virtual API Key + // 110 - Allowed Redirect // 201 - Resource Not Found // 202 - Resource Blocked @@ -300,6 +301,7 @@ export default function GeneralPage() { 107: t("validSSO"), 108: t("connectedClient"), 109: t("validVirtualAPIKey"), + 110: t("allowedRedirect"), 201: t("resourceNotFound"), 202: t("resourceBlocked"), 203: t("droppedByRule"), @@ -605,6 +607,7 @@ export default function GeneralPage() { { value: "106", label: t("validEmail") }, { value: "107", label: t("validSSO") }, { value: "108", label: t("connectedClient") }, + { value: "110", label: t("allowedRedirect") }, { value: "201", label: t("resourceNotFound") }, { value: "202", label: t("resourceBlocked") }, { value: "203", label: t("droppedByRule") }, diff --git a/src/app/[orgId]/settings/redirects/[niceId]/page.tsx b/src/app/[orgId]/settings/redirects/[niceId]/page.tsx new file mode 100644 index 000000000..7a0f11f21 --- /dev/null +++ b/src/app/[orgId]/settings/redirects/[niceId]/page.tsx @@ -0,0 +1,74 @@ +import RedirectForm from "@app/components/RedirectForm"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { GetRedirectResponse } from "@server/routers/redirect"; +import type { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +export const metadata: Metadata = { + title: "Edit Redirect" +}; + +export const dynamic = "force-dynamic"; + +type EditRedirectPageProps = { + params: Promise<{ orgId: string; niceId: string }>; +}; + +export default async function EditRedirectPage(props: EditRedirectPageProps) { + const { orgId, niceId } = await props.params; + const t = await getTranslations(); + + let redirect: GetRedirectResponse["redirect"]; + try { + const res = await internal.get>( + `/org/${orgId}/redirect/${niceId}`, + await authCookieHeader() + ); + redirect = res.data.data.redirect; + } catch { + notFound(); + } + + // The resource selector needs the resource's display fields up front so the + // trigger shows a name instead of a bare id before the list query resolves. + const initialResource = + redirect.resourceId && redirect.resourceNiceId + ? { + domainId: redirect.resourceDomainId, + resourceId: redirect.resourceId, + niceId: redirect.resourceNiceId, + name: redirect.resourceName ?? redirect.resourceNiceId, + fullDomain: redirect.resourceFullDomain, + ssl: redirect.resourceSsl ?? false, + wildcard: redirect.resourceWildcard ?? false + } + : null; + + return ( + <> +
+ + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/redirects/create/page.tsx b/src/app/[orgId]/settings/redirects/create/page.tsx new file mode 100644 index 000000000..2e6f0245d --- /dev/null +++ b/src/app/[orgId]/settings/redirects/create/page.tsx @@ -0,0 +1,39 @@ +import RedirectForm from "@app/components/RedirectForm"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Create Redirect" +}; + +type CreateRedirectPageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function CreateRedirectPage( + props: CreateRedirectPageProps +) { + const { orgId } = await props.params; + const t = await getTranslations(); + + return ( + <> +
+ + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx new file mode 100644 index 000000000..b4bb18f25 --- /dev/null +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -0,0 +1,150 @@ +import RedirectsTable, { + type RedirectFilterDomain +} from "@app/components/RedirectsTable"; +import type { SelectedResource } from "@app/components/resource-selector"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import type { ListRedirectsResponse } from "@server/routers/redirect"; +import { GetResourceResponse } from "@server/routers/resource/getResource"; +import type ResponseT from "@server/types/Response"; +import type { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "Redirects" +}; + +type RedirectIndexPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +function parsePositiveInt(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return undefined; + return parsed; +} + +export default async function RedirectIndexPage(props: RedirectIndexPageProps) { + const { orgId } = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); + const t = await getTranslations(); + const header = await authCookieHeader(); + + let redirects: ListRedirectsResponse["redirects"] = []; + let pagination: ListRedirectsResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${orgId}/redirects?${searchParams.toString()}`, + header + ); + const responseData = res.data.data; + redirects = responseData.redirects; + pagination = responseData.pagination; + } catch { + // empty list on error + } + + const resourceIdParam = parsePositiveInt(searchParams.get("resourceId")); + let initialFilterResource: SelectedResource | null = null; + if (resourceIdParam) { + try { + const resourceRes = await internal.get( + `/resource/${resourceIdParam}`, + header + ); + const resource = ( + resourceRes.data as ResponseT + ).data; + if (resource && resource.orgId === orgId) { + initialFilterResource = { + name: resource.name, + resourceId: resource.resourceId, + fullDomain: resource.fullDomain, + niceId: resource.niceId, + ssl: resource.ssl, + wildcard: resource.wildcard + }; + } + } catch { + // leave null so the table falls back to the id + } + } + + const domainIdParam = searchParams.get("domainId"); + let initialFilterDomain: RedirectFilterDomain | null = null; + if (domainIdParam) { + try { + const domainRes = await internal.get( + `/org/${orgId}/domain/${domainIdParam}`, + header + ); + const domain = (domainRes.data as ResponseT) + .data; + if (domain) { + initialFilterDomain = { + domainId: domain.domainId, + baseDomain: domain.baseDomain + }; + } + } catch { + // leave null so the table falls back to the id + } + } + + const redirectRows = redirects.map((redirect) => ({ + redirectId: redirect.redirectId, + niceId: redirect.niceId, + name: redirect.name, + subdomain: redirect.subdomain, + destinationHost: redirect.destinationHost, + pathMatchType: redirect.pathMatchType, + matchPath: redirect.matchPath, + rewritePath: redirect.rewritePath, + rewritePathType: redirect.rewritePathType, + priority: redirect.priority, + permanent: redirect.permanent, + ssl: redirect.ssl, + enabled: redirect.enabled, + resourceId: redirect.resourceId, + resourceName: redirect.resourceName, + resourceNiceId: redirect.resourceNiceId, + resourceFullDomain: redirect.resourceFullDomain, + resourceSsl: redirect.resourceSsl, + resourceDomainId: redirect.resourceDomainId, + domainId: redirect.domainId, + baseDomain: redirect.baseDomain + })); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx index c764b0446..59875f31a 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -80,7 +80,7 @@ export default function PrivateResourceHttpPage() { - {t("httpSettings")} + {t("resourceTypeHttp")} {t( diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index 31fe7cfae..554f794c2 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -62,7 +62,10 @@ export default async function PrivateResourceLayout( href: `/{orgId}/settings/resources/private/{niceId}/general` }, { - title: t(modeSettingsKey), + title: + siteResource.mode === "http" + ? t("resourceTypeHttp") + : t(modeSettingsKey), href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode === "inference" ? "ai-gateway" : siteResource.mode}` }, { diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 790de01cf..3427e99a1 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -151,7 +151,7 @@ export default function CreatePrivateResourcePage() { }, { value: "http" as const, - title: t("createInternalResourceDialogModeHttp"), + title: t("resourceTypeHttp"), description: t("privateResourceTypeHttpDescription") }, { @@ -602,7 +602,7 @@ export default function CreatePrivateResourcePage() { - {t("httpSettings")} + {t("resourceTypeHttp")} {t( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 2bee1be7b..511fa6ac4 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -88,7 +88,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/public/{niceId}/general` }, { - title: t(`${resource.mode}Settings`), + title: + resource.mode === "http" + ? t("resourceTypeHttp") + : t(`${resource.mode}Settings`), href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode === "inference" ? "ai-gateway" : resource.mode}` } ]; diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index f05052001..598b691ad 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -770,7 +770,7 @@ export default function Page() { { title: string; description: string } > = { http: { - title: t("createInternalResourceDialogModeHttp"), + title: t("resourceTypeHttp"), description: t("resourceTypeHttpDescription") }, inference: { diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 72504fd61..17d8fbb96 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -10,7 +10,6 @@ import { Cable, ChartLine, Coins, - Combine, CreditCard, Fingerprint, Globe, @@ -21,11 +20,11 @@ import { LayoutGrid, Link as LinkIcon, Logs, - MessageSquare, MessagesSquare, MonitorUp, Plug, ReceiptText, + Repeat, ScanEye, Server, Settings, @@ -141,6 +140,11 @@ export const orgNavSections = ( href: "/{orgId}/settings/domains", icon: }, + { + title: "sidebarRedirects", + href: "/{orgId}/settings/redirects", + icon: + }, ...(env?.flags.usePangolinDns ? [ { @@ -467,6 +471,11 @@ export const commandBarNavSections = ( title: "commandMachineClients", icon: }, + { + title: "sidebarRedirects", + href: "/{orgId}/settings/redirects", + icon: + }, ...(env?.flags.usePangolinDns ? [ { diff --git a/src/components/PathMatchRenameModal.tsx b/src/components/PathMatchRenameModal.tsx index 6474da6b9..7f9b30de7 100644 --- a/src/components/PathMatchRenameModal.tsx +++ b/src/components/PathMatchRenameModal.tsx @@ -1,11 +1,8 @@ import { Settings } from "lucide-react"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; + DescribedSelect, + type DescribedSelectOption +} from "@app/components/DescribedSelect"; import { Badge } from "@app/components/ui/badge"; import { Label } from "@app/components/ui/label"; @@ -24,6 +21,30 @@ import { } from "./Credenza"; import { useTranslations } from "next-intl"; +type PathMatchType = "prefix" | "exact" | "regex"; +type RewritePathType = "prefix" | "exact" | "regex" | "stripPrefix"; + +const pathMatchTypes: PathMatchType[] = ["prefix", "exact", "regex"]; + +function isPathMatchType( + value: string | null | undefined +): value is PathMatchType { + return pathMatchTypes.includes(value as PathMatchType); +} + +const rewritePathTypes: RewritePathType[] = [ + "prefix", + "exact", + "regex", + "stripPrefix" +]; + +function isRewritePathType( + value: string | null | undefined +): value is RewritePathType { + return rewritePathTypes.includes(value as RewritePathType); +} + export function PathMatchModal({ value, onChange, @@ -39,20 +60,42 @@ export function PathMatchModal({ const t = useTranslations(); const [open, setOpen] = useState(false); - const [matchType, setMatchType] = useState( - value?.pathMatchType || "prefix" + const [matchType, setMatchType] = useState( + isPathMatchType(value?.pathMatchType) ? value.pathMatchType : "prefix" ); const [path, setPath] = useState(value?.path || ""); + const matchOptions: DescribedSelectOption[] = [ + { + value: "prefix", + title: t("pathMatchPrefix"), + description: t("pathMatchPrefixHelp") + }, + { + value: "exact", + title: t("pathMatchExact"), + description: t("pathMatchExactHelp") + }, + { + value: "regex", + title: t("pathMatchRegex"), + description: t("pathMatchRegexHelp") + } + ]; + useEffect(() => { if (open) { - setMatchType(value?.pathMatchType || "prefix"); + setMatchType( + isPathMatchType(value?.pathMatchType) + ? value.pathMatchType + : "prefix" + ); setPath(value?.path || ""); } }, [open, value]); const handleSave = () => { - onChange({ pathMatchType: matchType as any, path: path.trim() }); + onChange({ pathMatchType: matchType, path: path.trim() }); setOpen(false); }; @@ -66,19 +109,6 @@ export function PathMatchModal({ ? t("pathMatchRegexPlaceholder") : t("pathMatchDefaultPlaceholder"); - const getHelpText = () => { - switch (matchType) { - case "prefix": - return t("pathMatchPrefixHelp"); - case "exact": - return t("pathMatchExactHelp"); - case "regex": - return t("pathMatchRegexHelp"); - default: - return ""; - } - }; - return ( {trigger} @@ -91,23 +121,21 @@ export function PathMatchModal({
- - + + + options={matchOptions} + value={matchType} + onChange={setMatchType} + searchPlaceholder={t("search")} + emptyMessage={t("noResults")} + /> +

+ { + matchOptions.find( + (option) => option.value === matchType + )?.description + } +

@@ -155,21 +180,50 @@ export function PathRewriteModal({ }) { const t = useTranslations(); const [open, setOpen] = useState(false); - const [rewriteType, setRewriteType] = useState( - value?.rewritePathType || "prefix" + const [rewriteType, setRewriteType] = useState( + isRewritePathType(value?.rewritePathType) + ? value.rewritePathType + : "prefix" ); const [rewritePath, setRewritePath] = useState(value?.rewritePath || ""); + const rewriteOptions: DescribedSelectOption[] = [ + { + value: "prefix", + title: t("pathRewritePrefix"), + description: t("pathRewritePrefixHelp") + }, + { + value: "exact", + title: t("pathRewriteExact"), + description: t("pathRewriteExactHelp") + }, + { + value: "regex", + title: t("pathRewriteRegex"), + description: t("pathRewriteRegexHelp") + }, + { + value: "stripPrefix", + title: t("pathRewriteStripPrefix"), + description: t("pathRewriteStripPrefixHelp") + } + ]; + useEffect(() => { if (open) { - setRewriteType(value?.rewritePathType || "prefix"); + setRewriteType( + isRewritePathType(value?.rewritePathType) + ? value.rewritePathType + : "prefix" + ); setRewritePath(value?.rewritePath || ""); } }, [open, value]); const handleSave = () => { onChange({ - rewritePathType: rewriteType as any, + rewritePathType: rewriteType, rewritePath: rewritePath.trim() }); setOpen(false); @@ -191,21 +245,6 @@ export function PathRewriteModal({ } }; - const getHelpText = () => { - switch (rewriteType) { - case "prefix": - return t("pathRewritePrefixHelp"); - case "exact": - return t("pathRewriteExactHelp"); - case "regex": - return t("pathRewriteRegexHelp"); - case "stripPrefix": - return t("pathRewriteStripPrefixHelp"); - default: - return ""; - } - }; - return ( !disabled && setOpen(v)}> {trigger} @@ -218,31 +257,21 @@ export function PathRewriteModal({
- - + onChange={setRewriteType} + searchPlaceholder={t("search")} + emptyMessage={t("noResults")} + /> +

+ { + rewriteOptions.find( + (option) => option.value === rewriteType + )?.description + } +

diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 9d44562a2..a6bb26ec5 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -49,6 +49,14 @@ function getActionsCategories(root: boolean) { [t("actionRestartOrgDomain")]: "restartOrgDomain" }, + Redirect: { + [t("actionCreateRedirect")]: "createRedirect", + [t("actionDeleteRedirect")]: "deleteRedirect", + [t("actionGetRedirect")]: "getRedirect", + [t("actionListRedirects")]: "listRedirects", + [t("actionUpdateRedirect")]: "updateRedirect" + }, + Site: { [t("actionCreateSite")]: "createSite", [t("actionDeleteSite")]: "deleteSite", diff --git a/src/components/PrivateResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx index bec193bae..8db5ac9f8 100644 --- a/src/components/PrivateResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -91,7 +91,7 @@ export function PrivateResourceInfoSections({ const modeLabel: Record = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), - http: t("editInternalResourceDialogModeHttp"), + http: t("resourceTypeHttp"), ssh: t("editInternalResourceDialogModeSsh"), inference: t("editInternalResourceDialogModeInference"), gateway: t("editInternalResourceDialogModeGateway") diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx new file mode 100644 index 000000000..56846d3bc --- /dev/null +++ b/src/components/RedirectForm.tsx @@ -0,0 +1,943 @@ +"use client"; + +import DomainPicker from "@app/components/DomainPicker"; +import { + PathMatchDisplay, + PathMatchModal, + PathRewriteDisplay, + PathRewriteModal +} from "@app/components/PathMatchRenameModal"; +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionGrid, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { build } from "@server/build"; +import type { + CreateRedirectResponse, + GetRedirectResponse +} from "@server/routers/redirect"; +import { + isValidDestinationHost, + isValidRegex +} from "@server/routers/redirect/validation"; +import type { AxiosResponse } from "axios"; +import { ChevronDown, ChevronUp, InfoIcon, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ResourceSelector, type SelectedResource } from "./resource-selector"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "./ui/tooltip"; +import { Alert, AlertDescription } from "./ui/alert"; + +const DEFAULT_PATH_MATCH_TYPE = "regex" as const; +const DEFAULT_PRIORITY = 100; + +export type ExistingRedirect = GetRedirectResponse["redirect"]; + +type RedirectFormProps = { + orgId: string; + /** Omit to create a new redirect. */ + redirect?: ExistingRedirect; + /** Name/domain of the resource the redirect is attached to, when there is one. */ + initialResource?: SelectedResource | null; +}; + +export default function RedirectForm({ + orgId, + redirect, + initialResource = null +}: RedirectFormProps) { + const isEditing = Boolean(redirect); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const [, formAction, saveLoading] = useActionState(onSubmit, null); + const [selectedResource, setSelectedResource] = + useState(initialResource); + + // DomainPicker only hands back the composed host through its callback, so + // keep it locally; seed from the saved redirect for the edit case. + const [domainFullDomain, setDomainFullDomain] = useState( + redirect?.baseDomain + ? [redirect.subdomain, redirect.baseDomain] + .filter(Boolean) + .join(".") + : null + ); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + + const formSchema = useMemo( + () => + z + .object({ + name: z + .string() + .trim() + .min(1, { message: t("nameRequired") }), + attachTo: z.enum(["domain", "resource"]), + domainId: z.string().nullable(), + subdomain: z.string().nullable(), + resourceId: z.number().int().positive().nullable(), + destinationHost: z + .string() + .trim() + .min(1, { + message: t("redirectDestinationHostRequired") + }) + .refine(isValidDestinationHost, { + message: t("redirectDestinationHostInvalid") + }), + pathMatchType: z.enum(["exact", "prefix", "regex"]), + matchPath: z.string().trim().nullable(), + rewritePath: z.string().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .nullable(), + priority: z + .number() + .int() + .min(1, { message: t("redirectPriorityInvalid") }) + .max(1000, { message: t("redirectPriorityInvalid") }), + permanent: z.boolean(), + ssl: z.boolean(), + enabled: z.boolean() + }) + .superRefine((data, ctx) => { + if (data.attachTo === "domain" && !data.domainId) { + ctx.addIssue({ + code: "custom", + message: t("redirectDomainRequired"), + path: ["domainId"] + }); + } + if (data.attachTo === "resource" && !data.resourceId) { + ctx.addIssue({ + code: "custom", + message: t("redirectResourceRequired"), + 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 + // the one rewrite type that needs no replacement value. + if ( + data.rewritePathType && + data.rewritePathType !== "stripPrefix" && + !data.rewritePath + ) { + ctx.addIssue({ + code: "custom", + message: t("redirectRewritePathRequired"), + path: ["rewritePath"] + }); + } + }), + [t] + ); + + type RedirectFormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: redirect?.name ?? "", + attachTo: redirect?.resourceId ? "resource" : "domain", + domainId: redirect?.domainId ?? null, + subdomain: redirect?.subdomain ?? null, + resourceId: redirect?.resourceId ?? null, + destinationHost: redirect?.destinationHost ?? "", + pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE, + matchPath: redirect?.matchPath ?? null, + rewritePath: redirect?.rewritePath ?? null, + rewritePathType: redirect?.rewritePathType ?? null, + priority: redirect?.priority ?? DEFAULT_PRIORITY, + permanent: redirect?.permanent ?? false, + ssl: redirect?.ssl ?? true, + enabled: redirect?.enabled ?? true + } + }); + + const attachTo = form.watch("attachTo"); + const ssl = form.watch("ssl"); + + const resourceMissingDomain = + attachTo === "resource" && + Boolean(selectedResource) && + !selectedResource?.domainId; + + 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 && sourceHost && redirect.destinationHost === sourceHost + ) + ); + + useEffect(() => { + if (sameDomainAsSource && sourceHost) { + form.setValue("destinationHost", sourceHost, { + shouldValidate: true + }); + } + }, [sameDomainAsSource, sourceHost, form]); + const pathMatchType = form.watch("pathMatchType"); + const rewritePath = form.watch("rewritePath"); + const rewritePathType = form.watch("rewritePathType"); + // stripPrefix is a valid rewrite with no path value, so it counts as set. + const hasRewrite = + Boolean(rewritePath) || rewritePathType === "stripPrefix"; + + async function onSubmit() { + if (!(await form.trigger())) return; + + const values = form.getValues(); + + // Only one of the two attachment points is ever persisted; clear the + // other so switching between them doesn't leave a stale reference. + const body = { + name: values.name.trim(), + domainId: values.attachTo === "domain" ? values.domainId : null, + subdomain: + values.attachTo === "domain" ? values.subdomain || null : null, + resourceId: + values.attachTo === "resource" ? values.resourceId : null, + destinationHost: values.destinationHost.trim(), + pathMatchType: values.pathMatchType, + matchPath: values.matchPath?.trim() || null, + rewritePath: values.rewritePath?.trim() || null, + rewritePathType: values.rewritePathType, + priority: values.priority, + permanent: values.permanent, + // Resource-attached redirects inherit the resource's ssl setting + ssl: values.attachTo === "domain" ? values.ssl : true, + enabled: values.enabled + }; + + try { + if (isEditing) { + await api.post( + `/org/${orgId}/redirects/${redirect!.redirectId}`, + body + ); + toast({ + title: t("success"), + description: t("redirectUpdated") + }); + router.refresh(); + } else { + const res = await api.put< + AxiosResponse + >(`/org/${orgId}/redirect`, body); + toast({ + title: t("success"), + description: t("redirectCreated") + }); + const niceId = res.data.data.redirect.niceId; + router.push(`/${orgId}/settings/redirects/${niceId}`); + } + } catch (e) { + toast({ + variant: "destructive", + title: isEditing + ? t("redirectErrorUpdate") + : t("redirectErrorCreate"), + description: formatAxiosError( + e, + isEditing + ? t("redirectErrorUpdate") + : t("redirectErrorCreate") + ) + }); + } + } + + return ( + +
+ + + + + {t("general")} + + + + + + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + + + + + )} + /> + + + + + + {showAdvancedSettings && ( + <> + + ( + + + {t("priority")} + + + + field.onChange( + e + .target + .valueAsNumber + ) + } + /> + + + {t( + "redirectPriorityDescription" + )} + + + + )} + /> + + + + ( + + + + + + + )} + /> + + + )} + + + + + + + + + + {t("redirectSource")} + + + {t("redirectSourceSectionDescription")} + + + + + + + {resourceMissingDomain && ( + + + + + {t( + "redirectDomainSelectedDomainNotFound" + )} + + + + )} + + + ( + + + {t( + "redirectAttachedTo" + )} + + + + + )} + /> + + {attachTo === "domain" ? ( + + ( + + { + form.setValue( + "domainId", + res?.domainId ?? + null, + { + shouldValidate: true + } + ); + form.setValue( + "subdomain", + res?.subdomain || + null + ); + setDomainFullDomain( + res?.fullDomain ?? + null + ); + }} + /> + + + )} + /> + + ) : ( + + ( + + + {t( + "selectedRedirectResource" + )} + + + + + + + + + { + setSelectedResource( + resource + ); + field.onChange( + resource.resourceId + ); + }} + /> + + + + + )} + /> + + )} + + {/* The cloud only serves HTTPS, and resource-attached redirects inherit TLS, so there is nothing to toggle in those cases. */} + {build !== "saas" && + attachTo === "domain" && ( + + ( + + + + + + + )} + /> + + )} + + + ( + + + {t("matchPath")} + + { + // No match path + // means the + // redirect applies + // to every path; + // pathMatchType is + // NOT NULL so it + // keeps a default. + field.onChange( + config.path || + null + ); + form.setValue( + "pathMatchType", + (config.pathMatchType as + | "exact" + | "prefix" + | "regex") || + DEFAULT_PATH_MATCH_TYPE + ); + }} + trigger={ + + } + /> + + {t( + "redirectMatchPathDescription" + )} + + + + )} + /> + + + + + + + + + + {t("redirectDestination")} + + + {t("redirectDestinationSectionDescription")} + + + + + + + + + + + + ( + + + {t( + "redirectDestinationHost" + )} + + + + + + {t( + "redirectDestinationHostDescription" + )} + + + + )} + /> + + + + ( + + + {t("rewritePath")} + + { + field.onChange( + config.rewritePath || + null + ); + form.setValue( + "rewritePathType", + (config.rewritePathType as + | "exact" + | "prefix" + | "regex" + | "stripPrefix" + | null) ?? + null + ); + }} + trigger={ + hasRewrite ? ( + + ) : ( + + ) + } + /> + + {t( + "redirectRewritePathDescription" + )} + + + + )} + /> + + + + + + + +
+ + +
+
+ +
+ ); +} diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx new file mode 100644 index 000000000..9519cd150 --- /dev/null +++ b/src/components/RedirectsTable.tsx @@ -0,0 +1,861 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + ResourceSelector, + type SelectedResource +} from "@app/components/resource-selector"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Switch } from "@app/components/ui/switch"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { orgQueries } from "@app/lib/queries"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { useQuery } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; +import { + ArrowDown, + ArrowDown01Icon, + ArrowRight, + ArrowUp, + ArrowUp10Icon, + ArrowUpRight, + CheckIcon, + ChevronsUpDownIcon, + Funnel, + MinusIcon, + MoreHorizontal +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; +import { + useMemo, + useOptimistic, + useRef, + useState, + useTransition, + type ComponentRef, + type ReactNode +} from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { InfoPopup } from "./ui/info-popup"; + +const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + +export type RedirectRow = { + redirectId: number; + niceId: string; + name: string; + subdomain: string | null; + destinationHost: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string | null; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; + priority: number | null; + permanent: boolean; + ssl: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + resourceFullDomain: string | null; + resourceSsl: boolean | null; + resourceDomainId: string | null; + domainId: string | null; + baseDomain: string | null; +}; + +export type RedirectFilterDomain = { + domainId: string; + baseDomain: string; +}; + +type RedirectsTableProps = { + redirects: RedirectRow[]; + orgId: string; + pagination: PaginationState; + rowCount: number; + initialFilterResource?: SelectedResource | null; + initialFilterDomain?: RedirectFilterDomain | null; +}; + +export default function RedirectsTable({ + redirects, + orgId, + pagination, + rowCount, + initialFilterResource = null, + initialFilterDomain = null +}: RedirectsTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [selected, setSelected] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + const [domainFilterOpen, setDomainFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); + + const domainIdQ = searchParams.get("domainId"); + const selectedDomain: RedirectFilterDomain | null = useMemo(() => { + if (!domainIdQ) { + return null; + } + if (initialFilterDomain && initialFilterDomain.domainId === domainIdQ) { + return initialFilterDomain; + } + return { + domainId: domainIdQ, + baseDomain: t("redirectFilterDomainFallback", { id: domainIdQ }) + }; + }, [domainIdQ, initialFilterDomain, t]); + + const resourceIdQ = searchParams.get("resourceId"); + const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN; + const selectedResource: SelectedResource | null = useMemo(() => { + if ( + !resourceIdQ || + !Number.isInteger(resourceIdNum) || + resourceIdNum <= 0 + ) { + return null; + } + if ( + initialFilterResource && + initialFilterResource.resourceId === resourceIdNum + ) { + return initialFilterResource; + } + return { + name: t("redirectFilterResourceFallback", { id: resourceIdNum }), + resourceId: resourceIdNum, + fullDomain: null, + niceId: "", + ssl: false, + wildcard: false + }; + }, [initialFilterResource, resourceIdQ, resourceIdNum, t]); + + function refreshData() { + startTransition(() => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + + function handleFilterChange( + column: string, + value: string | undefined | null | string[] + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (typeof value === "string") { + searchParams.set(column, value); + } else if (value) { + value.forEach((val) => searchParams.append(column, val)); + } + + filter({ searchParams }); + } + + const clearDomainFilter = () => { + handleFilterChange("domainId", undefined); + setDomainFilterOpen(false); + }; + + const clearResourceFilter = () => { + handleFilterChange("resourceId", undefined); + setResourceFilterOpen(false); + }; + + const onPickDomain = (domain: RedirectFilterDomain) => { + handleFilterChange("domainId", domain.domainId); + setDomainFilterOpen(false); + }; + + const onPickResource = (resource: SelectedResource) => { + handleFilterChange("resourceId", String(resource.resourceId)); + setResourceFilterOpen(false); + }; + + function toggleSort(column: string) { + filter({ searchParams: getNextSortOrder(column, searchParams) }); + } + + // Prefix matches/rewrites cover everything beneath the path, so show the + // implied glob rather than the bare prefix. + function withPrefixGlob(path: string) { + return path.endsWith("/") ? `${path}*` : `${path}/*`; + } + + async function toggleEnabled(enabled: boolean, redirectId: number) { + try { + await api.post(`/org/${orgId}/redirects/${redirectId}`, { + enabled + }); + toast({ + title: t("success"), + description: t("redirectUpdated") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("redirectErrorUpdate"), + description: formatAxiosError(e, t("redirectErrorUpdate")) + }); + } + } + + function deleteRedirect(row: RedirectRow) { + startTransition(async () => { + try { + await api.delete(`/org/${orgId}/redirects/${row.redirectId}`); + setIsDeleteModalOpen(false); + setSelected(null); + toast({ + title: t("success"), + description: t("redirectDeleted") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("redirectErrorDelete"), + description: formatAxiosError(e, t("redirectErrorDelete")) + }); + } + }); + } + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + }, + cell: ({ row }) => {row.original.name} + }, + { + id: "niceId", + accessorKey: "niceId", + friendlyName: t("identifier"), + header: () => {t("identifier")}, + cell: ({ row }) => {row.original.niceId} + }, + { + id: "domain", + friendlyName: t("domain"), + header: () => ( + + + + ), + cell: ({ row }) => { + const redirect = row.original; + + if (!redirect.domainId || !redirect.baseDomain) { + return -; + } + + return ( + + ); + } + }, + { + id: "resource", + friendlyName: t("resource"), + header: () => ( + + + + ), + cell: ({ row }) => { + const redirect = row.original; + + if ( + !redirect.resourceId || + !redirect.resourceName || + !redirect.resourceNiceId + ) { + return -; + } + + return ( + + ); + } + }, + + { + id: "source", + friendlyName: t("redirectSource"), + header: () => ( + {t("redirectSource")} + ), + cell: ({ row }) => { + const redirect = row.original; + // A domain-attached redirect may target a specific host + // under the base domain, e.g. old.example.com. + const domainHost = redirect.baseDomain + ? [redirect.subdomain, redirect.baseDomain] + .filter(Boolean) + .join(".") + : null; + const host = redirect.resourceFullDomain ?? domainHost; + + if (redirect.resourceId && !redirect.resourceDomainId) { + return ( + + ); + } + + const matchPath = redirect.matchPath + ? redirect.pathMatchType === "prefix" + ? withPrefixGlob(redirect.matchPath) + : redirect.matchPath + : ""; + // Resource-attached redirects inherit the resource's ssl. + const sourceSsl = redirect.resourceId + ? (redirect.resourceSsl ?? true) + : redirect.ssl; + const source = host + ? `${sourceSsl ? "https" : "http"}://${host}${matchPath}` + : matchPath; + + if (!source) { + return -; + } + + return ; + } + }, + { + id: "destination", + accessorKey: "destinationHost", + friendlyName: t("redirectDestination"), + header: () => ( + {t("redirectDestination")} + ), + cell: ({ row }) => { + const redirect = row.original; + const rewritePath = redirect.rewritePath + ? redirect.rewritePathType === "prefix" + ? withPrefixGlob(redirect.rewritePath) + : redirect.rewritePath + : ""; + const destination = `${redirect.destinationHost}${rewritePath}`; + + if (!destination) { + return -; + } + + return ( + + ); + } + }, + { + accessorKey: "priority", + friendlyName: t("priority"), + header: () => { + const priorityOrder = getSortDirection( + "priority", + searchParams + ); + const Icon = + priorityOrder === "asc" + ? ArrowDown01Icon + : priorityOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + }, + cell: ({ row }) => { + // 100 is the automatic default; anything else was set + // deliberately, so flag which way it deviates. + const priority = row.original.priority ?? 100; + return ( + + {priority} + {priority > 100 ? ( + + ) : priority < 100 ? ( + + ) : ( + + )} + + ); + } + }, + { + accessorKey: "permanent", + friendlyName: t("redirectType"), + header: () => ( + + handleFilterChange("type", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("redirectType")} + className="p-3" + /> + ), + cell: ({ row }) => ( + + {row.original.permanent + ? t("redirectTypePermanent") + : t("redirectTypeTemporary")} + + ) + }, + { + accessorKey: "enabled", + friendlyName: t("enabled"), + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), + cell: ({ row }) => ( + + ) + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => ( +
+ + + + + + + + {t("edit")} + + + { + setSelected(row.original); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ) + } + ], + [ + orgId, + t, + searchParams, + domainFilterOpen, + resourceFilterOpen, + selectedDomain, + selectedResource + ] + ); + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + if (!val) { + setSelected(null); + } + }} + dialog={ +
+

{t("redirectQuestionRemove")}

+

{t("redirectMessageRemove")}

+
+ } + buttonText={t("redirectDeleteConfirm")} + onConfirm={async () => deleteRedirect(selected)} + string={selected.name} + title={t("redirectDelete")} + /> + )} + + + startNavigation(() => + router.push(`/${orgId}/settings/redirects/create`) + ) + } + isNavigatingToAddPage={isNavigatingToAddPage} + tableId="redirects-table" + searchPlaceholder={t("redirectsSearch")} + pagination={pagination} + onPaginationChange={handlePaginationChange} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + rowCount={rowCount} + columnVisibility={{ + niceId: false, + priority: false + }} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + ); +} + +type RedirectEnabledFormProps = { + redirect: RedirectRow; + onToggleEnabled: (val: boolean, redirectId: number) => Promise; +}; + +function RedirectEnabledForm({ + redirect, + onToggleEnabled +}: RedirectEnabledFormProps) { + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic( + redirect.enabled + ); + + const missingDomain = Boolean( + redirect.resourceId && !redirect.resourceDomainId + ); + + const formRef = useRef>(null); + + async function submitAction(formData: FormData) { + if (missingDomain) return; + const newEnabled = !(formData.get("enabled") === "on"); + setOptimisticEnabled(newEnabled); + await onToggleEnabled(newEnabled, redirect.redirectId); + } + + return ( +
+ formRef.current?.requestSubmit()} + /> + + ); +} + +type ColumnEntityFilterProps = { + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + selectedName?: string | null; + clearLabel: string; + onClear: () => void; + children: ReactNode; +}; + +function ColumnEntityFilter({ + label, + open, + onOpenChange, + selectedName, + clearLabel, + onClear, + children +}: ColumnEntityFilterProps) { + return ( + + + + + +
+ +
+ {children} +
+
+ ); +} + +type DomainFilterListProps = { + orgId: string; + selectedDomainId: string | null; + onSelect: (domain: RedirectFilterDomain) => void; +}; + +function DomainFilterList({ + orgId, + selectedDomainId, + onSelect +}: DomainFilterListProps) { + const t = useTranslations(); + const { data: domains = [] } = useQuery(orgQueries.domains({ orgId })); + + return ( + + + + {t("domainsNotFound")} + + {domains.map((domain) => ( + + onSelect({ + domainId: domain.domainId, + baseDomain: domain.baseDomain + }) + } + > + + {domain.baseDomain} + + ))} + + + + ); +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 892766c6d..27c19136d 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,23 +1,46 @@ import { cn } from "@app/lib/cn"; -export function SettingsContainer({ children }: { children: React.ReactNode }) { - return
{children}
; +export function SettingsContainer({ + children, + className +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; } -export function SettingsSection({ children }: { children: React.ReactNode }) { +export function SettingsSection({ + children, + className +}: { + children: React.ReactNode; + className?: string; +}) { return ( -
+
{children}
); } export function SettingsSectionHeader({ - children + children, + className }: { children: React.ReactNode; + className?: string; }) { - return
{children}
; + return ( +
+ {children} +
+ ); } export function SettingsSectionForm({ @@ -77,7 +100,7 @@ export function SettingsFormCell({ "min-w-0", span === "quarter" && "md:col-span-1", span === "half" && "md:col-span-2", - span === "full" && "md:col-span-4", + span === "full" && "col-span-full", className )} > @@ -87,23 +110,36 @@ export function SettingsFormCell({ } export function SettingsSectionTitle({ - children + children, + className }: { children: React.ReactNode; + className?: string; }) { return ( -

+

{children}

); } export function SettingsSectionDescription({ - children + children, + className }: { children: React.ReactNode; + className?: string; }) { - return

{children}

; + return ( +

+ {children} +

+ ); } export function SettingsSubsectionHeader({ @@ -141,11 +177,15 @@ export function SettingsSubsectionDescription({ } export function SettingsSectionBody({ - children + children, + className }: { children: React.ReactNode; + className?: string; }) { - return
{children}
; + return ( +
{children}
+ ); } export function SettingsSectionFooter({ @@ -169,10 +209,22 @@ export function SettingsSectionFooter({ export function SettingsSectionGrid({ children, - cols + cols = 4, + className }: { children: React.ReactNode; - cols: number; + cols?: number; + className?: string; }) { - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index 5dad2bf0e..1537c403b 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -18,7 +18,7 @@ import { useDebounce } from "use-debounce"; export type SelectedResource = Pick< ListResourcesResponse["resources"][number], "name" | "resourceId" | "fullDomain" | "niceId" | "ssl" | "wildcard" ->; +> & { domainId?: string | null }; export type ResourceSelectorProps = { orgId: string; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index fe87fb0dc..6032d5d8e 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -44,6 +44,7 @@ const Input = React.forwardRef( data-slot="input" className={cn( "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "read-only:opacity-50", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "focus-visible:outline-none focus-visible:border-ring focus-visible:ring-offset-0", className diff --git a/src/hooks/useStoredColumnVisibility.ts b/src/hooks/useStoredColumnVisibility.ts index 1d5d66628..e2a1802f4 100644 --- a/src/hooks/useStoredColumnVisibility.ts +++ b/src/hooks/useStoredColumnVisibility.ts @@ -20,7 +20,9 @@ const getStoredColumnVisibility = ( const parsed = JSON.parse(stored); // Validate that it's an object if (typeof parsed === "object" && parsed !== null) { - return parsed; + // Stored choices win. Keys only present in the defaults + // (for example a newly hidden column) still apply. + return { ...(defaultVisibility || {}), ...parsed }; } } } catch (error) { @@ -60,8 +62,7 @@ export function useStoredColumnVisibility( const setColumnVisibility = useCallback( ( updaterOrValue: - | VisibilityState - | ((old: VisibilityState) => VisibilityState) + VisibilityState | ((old: VisibilityState) => VisibilityState) ) => { if (typeof updaterOrValue === "function") { setVisibility((oldValue) => {