From 080bcbaf97cc383324cf4dd3a6905aaac36c2614 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 7 Sep 2026 11:55:45 -0400 Subject: [PATCH 001/107] Use endpoint instead of reachableAt for remote nodes --- .../exitNodeReconnectScheduler.ts | 90 +++++++++---------- .../handleRemoteExitNodePingMessage.ts | 14 ++- 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts index 20bb46193..8cb3e80ec 100644 --- a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts +++ b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts @@ -16,7 +16,7 @@ import { db, exitNodes, newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import redisManager from "#private/lib/redis"; -// import { sendToClient } from "#private/routers/ws"; +import { sendToClient } from "#private/routers/ws"; const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds @@ -26,7 +26,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:"; interface PendingReconnect { startTime: number; - reachableAt: string; + endpoint: string; } // In-memory tracking for this node @@ -40,15 +40,15 @@ let schedulerInterval: NodeJS.Timeout | null = null; */ export async function scheduleExitNodeReconnect( exitNodeId: number, - reachableAt: string + endpoint: string ): Promise { logger.info( - `Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})` + `Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})` ); const entry: PendingReconnect = { startTime: Date.now(), - reachableAt + endpoint }; pendingReconnects.set(exitNodeId, entry); @@ -63,8 +63,8 @@ export async function scheduleExitNodeReconnect( ); await redisManager.hset( `${REDIS_HASH_PREFIX}${exitNodeId}`, - "reachableAt", - reachableAt + "endpoint", + endpoint ); } } @@ -101,14 +101,14 @@ async function processPendingReconnects(): Promise { `${REDIS_HASH_PREFIX}${id}`, "startTime" ); - const reachableAt = await redisManager.hget( + const endpoint = await redisManager.hget( `${REDIS_HASH_PREFIX}${id}`, - "reachableAt" + "endpoint" ); - if (startTimeStr && reachableAt) { + if (startTimeStr && endpoint) { toProcess.set(id, { startTime: parseInt(startTimeStr, 10), - reachableAt + endpoint }); } } @@ -135,7 +135,7 @@ async function processPendingReconnects(): Promise { } // Check if the exit node HTTP endpoint is reachable - const pingUrl = `${entry.reachableAt}/ping`; + const pingUrl = `http://${entry.endpoint}/ping`; try { await axios.get(pingUrl, { timeout: 5000 }); } catch { @@ -150,47 +150,47 @@ async function processPendingReconnects(): Promise { `Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.` ); - // await sendReconnectToNewts(exitNodeId); + await sendReconnectToNewts(exitNodeId); await removePending(exitNodeId); } } -// async function sendReconnectToNewts(exitNodeId: number): Promise { -// try { -// const connectedNewts = await db -// .select({ newtId: newts.newtId }) -// .from(newts) -// .innerJoin(sites, eq(newts.siteId, sites.siteId)) -// .where(eq(sites.exitNodeId, exitNodeId)); +async function sendReconnectToNewts(exitNodeId: number): Promise { + try { + const connectedNewts = await db + .select({ newtId: newts.newtId }) + .from(newts) + .innerJoin(sites, eq(newts.siteId, sites.siteId)) + .where(eq(sites.exitNodeId, exitNodeId)); -// if (connectedNewts.length === 0) { -// logger.debug( -// `No newts found for exit node ${exitNodeId}, nothing to reconnect` -// ); -// return; -// } + if (connectedNewts.length === 0) { + logger.debug( + `No newts found for exit node ${exitNodeId}, nothing to reconnect` + ); + return; + } -// logger.info( -// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}` -// ); + logger.info( + `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}` + ); -// const reconnectMessage = { -// type: "newt/wg/reconnect", -// data: {} -// }; + const reconnectMessage = { + type: "newt/wg/reconnect", + data: {} + }; -// await Promise.allSettled( -// connectedNewts.map(({ newtId }) => -// sendToClient(newtId, reconnectMessage) -// ) -// ); -// } catch (error) { -// logger.error( -// `Failed to send reconnect messages for exit node ${exitNodeId}`, -// { error } -// ); -// } -// } + await Promise.allSettled( + connectedNewts.map(({ newtId }) => + sendToClient(newtId, reconnectMessage) + ) + ); + } catch (error) { + logger.error( + `Failed to send reconnect messages for exit node ${exitNodeId}`, + { error } + ); + } +} async function removePending(exitNodeId: number): Promise { pendingReconnects.delete(exitNodeId); diff --git a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts index 10bf36d7c..14a777684 100644 --- a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts +++ b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts @@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async ( try { // Fetch the current state before updating so we can detect the offline→online transition const [currentExitNode] = await db - .select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt }) + .select({ online: exitNodes.online, endpoint: exitNodes.endpoint }) .from(exitNodes) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)) .limit(1); @@ -55,12 +55,18 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async ( .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); // If the exit node was offline and is now coming online, schedule newt reconnects - if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) { + if ( + currentExitNode && + !currentExitNode.online && + currentExitNode.endpoint + ) { scheduleExitNodeReconnect( remoteExitNode.exitNodeId, - currentExitNode.reachableAt + currentExitNode.endpoint ).catch((error) => { - logger.error("Failed to schedule exit node reconnect", { error }); + logger.error("Failed to schedule exit node reconnect", { + error + }); }); } } catch (error) { From 59b228ce3932dbcbe085c11488610b735d5fabae Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 8 Sep 2026 09:26:57 -0400 Subject: [PATCH 002/107] Quiet log message --- src/app/[orgId]/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index f85cbd03e..b3d79691b 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -104,7 +104,7 @@ export default async function OrgLayout(props: { subscriptionStatus = subRes.data.data; } catch (error) { // If subscription fetch fails, keep subscriptionStatus as null - console.error("Failed to fetch subscription status:", error); + // console.error("Failed to fetch subscription status:", error); } } From 733d3ece0e8b122632f7585086a8c748ab32349d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 8 Sep 2026 10:01:59 -0400 Subject: [PATCH 003/107] Quiet up error logs --- server/lib/dbRetry.ts | 2 +- server/private/routers/hybrid.ts | 7 ++++++- .../handleOlmServerInitAddPeerHandshake.ts | 20 +++++++++---------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/server/lib/dbRetry.ts b/server/lib/dbRetry.ts index 7f8d1eea8..9dcf84c7c 100644 --- a/server/lib/dbRetry.ts +++ b/server/lib/dbRetry.ts @@ -71,7 +71,7 @@ export async function withRetry( const jitter = Math.random() * baseDelay; const delay = baseDelay + jitter; logger.warn( - `Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`, + `Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`, { code: error?.code ?? error?.cause?.code } ); await new Promise((resolve) => setTimeout(resolve, delay)); diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index bf3afa382..caad19732 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -2478,7 +2478,12 @@ hybridRouter.post( destinations: destinations }); } catch (error) { - logger.error(error); + if (!( + error instanceof Error && + error.message === "Exit node not allowed" + )) { + logger.error(error); + } return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts index b3aaffb93..6bad5b89b 100644 --- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts +++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts @@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( ); if (!resources || resources.length === 0) { - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: Resource not found` ); await sendCancel(); @@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( if (resources.length > 1) { // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria` ); return; @@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( ); if (currentResourceAssociationCaches.length === 0) { - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` ); await sendCancel(); @@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( } if (!resource.networkId) { - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network` ); await sendCancel(); @@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( .where(eq(siteNetworks.networkId, resource.networkId)); if (!siteRows || siteRows.length === 0) { - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}` ); await sendCancel(); @@ -164,9 +164,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( } if (sitesToProcess.length === 0) { - logger.error( - `handleOlmServerInitAddPeerHandshake: No sites to process` - ); + logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`); await sendCancel(); return; } @@ -193,7 +191,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( } if (!site.exitNodeId) { - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping` ); continue; @@ -205,7 +203,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( .where(eq(exitNodes.exitNodeId, site.exitNodeId)); if (!exitNode) { - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping` ); continue; @@ -229,7 +227,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( } if (!handshakeInitiated) { - logger.error( + logger.warn( `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain` ); await sendCancel(); From b0e64a5e5a68cc7177c7dfdbca7994a91305a4ae Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 8 Sep 2026 16:31:58 -0400 Subject: [PATCH 004/107] Widen subnet --- server/lib/readConfigFile.ts | 4 ++-- .../routers/remoteExitNode/exitNodeReconnectScheduler.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 462d73c1c..c685821d1 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -348,8 +348,8 @@ export const configSchema = z .optional() .pipe(z.string()) .transform((url) => url.toLowerCase()), - subnet_group: z.string().optional().default("100.89.137.0/20"), - block_size: z.number().positive().gt(0).optional().default(24), + subnet_group: z.string().optional().default("100.89.137.0/18"), + block_size: z.number().positive().gt(0).optional().default(22), site_block_size: z .number() .positive() diff --git a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts index 8cb3e80ec..5d920fd3f 100644 --- a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts +++ b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts @@ -12,11 +12,11 @@ */ import axios from "axios"; -import { db, exitNodes, newts, sites } from "@server/db"; +import { db, newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import redisManager from "#private/lib/redis"; -import { sendToClient } from "#private/routers/ws"; +import { sendToClient } from "../ws"; const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds From 59f0c90836a5d0ba64a58e50ab8849a90d8037ea Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 8 Sep 2026 16:42:22 -0400 Subject: [PATCH 005/107] Fix circular import --- .../routers/remoteExitNode/exitNodeEvents.ts | 23 +++++++++++++++++++ .../exitNodeReconnectScheduler.ts | 16 +++++++++++++ .../handleRemoteExitNodePingMessage.ts | 12 ++++------ 3 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 server/private/routers/remoteExitNode/exitNodeEvents.ts diff --git a/server/private/routers/remoteExitNode/exitNodeEvents.ts b/server/private/routers/remoteExitNode/exitNodeEvents.ts new file mode 100644 index 000000000..ee5c753e4 --- /dev/null +++ b/server/private/routers/remoteExitNode/exitNodeEvents.ts @@ -0,0 +1,23 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { EventEmitter } from "events"; + +export interface ExitNodeOnlineEvent { + exitNodeId: number; + endpoint: string; +} + +export const EXIT_NODE_ONLINE_EVENT = "exit-node-online"; + +export const exitNodeEvents = new EventEmitter(); diff --git a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts index 5d920fd3f..8e4d1158a 100644 --- a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts +++ b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts @@ -17,6 +17,22 @@ import { eq } from "drizzle-orm"; import logger from "@server/logger"; import redisManager from "#private/lib/redis"; import { sendToClient } from "../ws"; +import { + exitNodeEvents, + EXIT_NODE_ONLINE_EVENT, + ExitNodeOnlineEvent +} from "./exitNodeEvents"; + +exitNodeEvents.on( + EXIT_NODE_ONLINE_EVENT, + ({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => { + scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => { + logger.error("Failed to schedule exit node reconnect", { + error + }); + }); + } +); const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds diff --git a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts index 14a777684..fcb094c5e 100644 --- a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts +++ b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts @@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws"; import { RemoteExitNode } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; -import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler"; +import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents"; /** * Handles ping messages from clients and responds with pong @@ -60,13 +60,9 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async ( !currentExitNode.online && currentExitNode.endpoint ) { - scheduleExitNodeReconnect( - remoteExitNode.exitNodeId, - currentExitNode.endpoint - ).catch((error) => { - logger.error("Failed to schedule exit node reconnect", { - error - }); + exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, { + exitNodeId: remoteExitNode.exitNodeId, + endpoint: currentExitNode.endpoint }); } } catch (error) { From 82c5dcf16f184758bb94f658c85a840575653c8c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 8 Sep 2026 16:45:56 -0400 Subject: [PATCH 006/107] Fix tsconfig to use react-jsx --- tsconfig.enterprise.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.enterprise.json b/tsconfig.enterprise.json index d36481830..bc7673fcb 100644 --- a/tsconfig.enterprise.json +++ b/tsconfig.enterprise.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "paths": { "@server/*": [ From 241ecc13e256035511b4b0740302da8171f58e61 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 9 Sep 2026 01:43:14 +0200 Subject: [PATCH 007/107] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 21 +++++++++++++++++++++ server/db/sqlite/schema/schema.ts | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7f4c04443..2ab34e83d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -227,6 +227,27 @@ 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: integer("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + sourcePath: varchar("sourcePath").notNull(), + destinationUrl: varchar("destinationUrl"), + permanent: boolean("permanent").notNull().default(false), + enabled: boolean("enabled").notNull().default(true) +}); + export const resourceAiProviders = pgTable( "resourceAiProviders", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2457785f3..c38fc4b54 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -243,6 +243,29 @@ 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: integer("domainId").references(() => domains.domainId, { + onDelete: "cascade" + }), + niceId: text("niceId").notNull(), + name: text("name").notNull(), + sourcePath: text("sourcePath").notNull(), + destinationUrl: text("destinationUrl"), + permanent: integer("permanent", { mode: "boolean" }) + .notNull() + .default(false), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) +}); + export const resourceAiProviders = sqliteTable( "resourceAiProviders", { From ae7315244fa3cdbeafba7f9edc7a5ff0de6224ba Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 10 Sep 2026 00:17:36 +0200 Subject: [PATCH 008/107] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20create=20DB=20m?= =?UTF-8?q?odels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 7 ++++++- server/db/names.ts | 25 +++++++++++++++++++++++++ server/db/pg/schema/schema.ts | 3 ++- server/db/sqlite/schema/schema.ts | 3 ++- server/openApi.ts | 3 ++- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 75caed82d..aed4cefe8 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -205,7 +205,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 2ab34e83d..38c617e0d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -237,7 +237,7 @@ export const redirects = pgTable("redirects", { resourceId: integer("resourceId").references(() => resources.resourceId, { onDelete: "cascade" }), - domainId: integer("domainId").references(() => domains.domainId, { + domainId: varchar("domainId").references(() => domains.domainId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), @@ -2086,6 +2086,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/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c38fc4b54..5fbabdb88 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -253,7 +253,7 @@ export const redirects = sqliteTable("redirects", { resourceId: integer("resourceId").references(() => resources.resourceId, { onDelete: "cascade" }), - domainId: integer("domainId").references(() => domains.domainId, { + domainId: text("domainId").references(() => domains.domainId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), @@ -2127,6 +2127,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/openApi.ts b/server/openApi.ts index b4b7c1a7a..b1eabea79 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -32,7 +32,8 @@ export enum OpenAPITags { AiProvider = "AI Provider", AiModel = "AI Model", AiBudget = "AI Budget", - VirtualApiKey = "Virtual API Key" + VirtualApiKey = "Virtual API Key", + Redirect = "Redirect" } // Order here controls the order tags are displayed in Swagger UI From 05d1d4d143d61f6f867b7971d5266a2c562d5ca8 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 10 Sep 2026 00:31:39 +0200 Subject: [PATCH 009/107] =?UTF-8?q?=F0=9F=9A=A7=20Redirect=20cRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/external.ts | 45 +++++ server/routers/redirect/createRedirect.ts | 172 ++++++++++++++++ server/routers/redirect/deleteRedirect.ts | 92 +++++++++ server/routers/redirect/getRedirect.ts | 135 +++++++++++++ server/routers/redirect/index.ts | 5 + server/routers/redirect/listRedirects.ts | 156 +++++++++++++++ server/routers/redirect/updateRedirect.ts | 232 ++++++++++++++++++++++ server/routers/redirect/validation.ts | 16 ++ 8 files changed, 853 insertions(+) create mode 100644 server/routers/redirect/createRedirect.ts create mode 100644 server/routers/redirect/deleteRedirect.ts create mode 100644 server/routers/redirect/getRedirect.ts create mode 100644 server/routers/redirect/index.ts create mode 100644 server/routers/redirect/listRedirects.ts create mode 100644 server/routers/redirect/updateRedirect.ts create mode 100644 server/routers/redirect/validation.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index d2935ff91..2ccd9154f 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"; @@ -1622,6 +1623,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/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts new file mode 100644 index 000000000..c1c94b9ad --- /dev/null +++ b/server/routers/redirect/createRedirect.ts @@ -0,0 +1,172 @@ +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 } from "drizzle-orm"; +import { redirectSourcePathSchema } from "@server/routers/redirect/validation"; +import { getUniqueRedirectName } from "@server/db/names"; + +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(), + sourcePath: redirectSourcePathSchema, + destinationUrl: z.url().optional().nullable(), + permanent: z.boolean().optional(), + enabled: z.boolean().optional() +}); + +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, + sourcePath, + destinationUrl, + permanent, + enabled + } = parsedBody.data; + + if (resourceId) { + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + } + + if (domainId) { + const [domain] = await db + .select({ domainId: domains.domainId }) + .from(domains) + .innerJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ) + .where( + and( + eq(domains.domainId, domainId), + eq(orgDomains.orgId, orgId) + ) + ) + .limit(1); + + 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, + sourcePath, + destinationUrl: destinationUrl ?? null, + permanent: permanent ?? false, + enabled: enabled ?? true + }) + .returning(); + + 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..979bf6035 --- /dev/null +++ b/server/routers/redirect/getRedirect.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { redirects, db } from "@server/db"; +import type { Redirect } 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: Redirect; +}; + +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() + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .limit(1); + return res; + } else if (niceId) { + const [res] = await db + .select() + .from(redirects) + .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..4310e9b10 --- /dev/null +++ b/server/routers/redirect/listRedirects.ts @@ -0,0 +1,156 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { redirects, db } 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, asc, eq, like, sql } from "drizzle-orm"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type ListRedirectsResponse = PaginatedResponse<{ + redirects: Redirect[]; +}>; + +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() +}); + +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 } = parsedQuery.data; + const conditions = [eq(redirects.orgId, orgId)]; + + if (query) { + conditions.push( + like( + sql`LOWER(${redirects.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = db + .select() + .from(redirects) + .where(and(...conditions)); + + const countQuery = db.$count( + db + .select() + .from(redirects) + .where(and(...conditions)) + .as("filtered_redirects") + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(redirects.name)) + ]); + + 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..7f10ae69f --- /dev/null +++ b/server/routers/redirect/updateRedirect.ts @@ -0,0 +1,232 @@ +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, + redirectSourcePathSchema +} from "@server/routers/redirect/validation"; + +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(), + sourcePath: redirectSourcePathSchema.optional(), + destinationUrl: z.url().optional().nullable(), + permanent: 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` + ) + ); + } + + 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` + ) + ); + } + } + + if (body.domainId) { + const [domain] = await db + .select({ domainId: domains.domainId }) + .from(domains) + .innerJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ) + .where( + and( + eq(domains.domainId, body.domainId), + eq(orgDomains.orgId, existing.orgId) + ) + ) + .limit(1); + + if (!domain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${body.domainId} 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` + ) + ); + } + } + + 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.sourcePath !== undefined) { + updateData.sourcePath = body.sourcePath; + } + if (body.destinationUrl !== undefined) { + updateData.destinationUrl = body.destinationUrl; + } + if (body.permanent !== undefined) { + updateData.permanent = body.permanent; + } + 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(); + + 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..49fe256bd --- /dev/null +++ b/server/routers/redirect/validation.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const redirectNiceIdSchema = z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ); + +export const redirectSourcePathSchema = z + .string() + .nonempty() + .regex(/^\//, "sourcePath must start with a /") + .default("/*"); From 2bccdc33fcb953ed7aa3a0fb8447f53a429ef96a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 10 Sep 2026 02:44:31 +0200 Subject: [PATCH 010/107] =?UTF-8?q?=F0=9F=9A=A7=20redirects=20index=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/app/[orgId]/settings/redirects/page.tsx | 18 ++++++++++++++++++ src/app/navigation.tsx | 6 ++++++ 3 files changed, 25 insertions(+) create mode 100644 src/app/[orgId]/settings/redirects/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7ed4906c1..ea3239b18 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2095,6 +2095,7 @@ "resourceBudgetSettings": "Budget", "resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits", "sidebarApiKeys": "API Keys", + "sidebarRedirects": "Redirects", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx new file mode 100644 index 000000000..a386e6570 --- /dev/null +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "Redirects" +}; + +type RedirectIndexPageProps = { + params: Promise<{ orgId: string }>; +}; +export const dynamic = "force-dynamic"; + +export default async function ApiKeysPage(props: RedirectIndexPageProps) { + const params = await props.params; + const t = await getTranslations(); + + return null; +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 80352bca8..820619cd8 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -25,6 +25,7 @@ import { MonitorUp, Plug, ReceiptText, + Repeat, ScanEye, Server, Settings, @@ -323,6 +324,11 @@ export const orgNavSections = ( href: "/{orgId}/settings/api-keys", icon: }, + { + title: "sidebarRedirects", + href: "/{orgId}/settings/redirects", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { From 30ca3e7e97e3361db74f1fa997deea698c785e5d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 10 Sep 2026 18:19:46 +0200 Subject: [PATCH 011/107] =?UTF-8?q?=E2=9C=A8=20list=20resources=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 20 +- server/routers/redirect/listRedirects.ts | 51 ++- src/app/[orgId]/settings/redirects/page.tsx | 65 +++- src/components/RedirectsTable.tsx | 367 ++++++++++++++++++++ 4 files changed, 490 insertions(+), 13 deletions(-) create mode 100644 src/components/RedirectsTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index ea3239b18..e741c9b56 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4364,5 +4364,23 @@ "rdpUnicodeKeyboardMode": "Unicode keyboard mode", "sessionToolbarShow": "Show toolbar", "sessionToolbarHide": "Hide toolbar", - "actionUpdateSiteApprovals": "Update Site Approvals" + "actionUpdateSiteApprovals": "Update Site Approvals", + "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", + "redirectAttachedTo": "Attached To", + "redirectDestination": "Destination", + "redirectType": "Type", + "redirectTypePermanent": "Permanent (301)", + "redirectTypeTemporary": "Temporary (302)", + "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." } diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 4310e9b10..28163d17f 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -1,18 +1,32 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { redirects, db } from "@server/db"; -import type { Redirect } from "@server/db"; +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, eq, like, sql } from "drizzle-orm"; +import { and, asc, eq, like, or, sql } from "drizzle-orm"; import type { PaginatedResponse } from "@server/types/Pagination"; export type ListRedirectsResponse = PaginatedResponse<{ - redirects: Redirect[]; + redirects: Array<{ + redirectId: number; + orgId: string; + niceId: string; + name: string; + sourcePath: string; + destinationUrl: string | null; + permanent: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + resourceFullDomain: string | null; + domainId: string | null; + baseDomain: string | null; + }>; }>; const paramsSchema = z.strictObject({ @@ -104,17 +118,36 @@ export async function listRedirects( const conditions = [eq(redirects.orgId, orgId)]; if (query) { + const term = "%" + query.toLowerCase() + "%"; conditions.push( - like( - sql`LOWER(${redirects.name})`, - "%" + query.toLowerCase() + "%" - ) + or( + like(sql`LOWER(${redirects.name})`, term), + like(sql`LOWER(${redirects.sourcePath})`, term), + like(sql`LOWER(${redirects.destinationUrl})`, term) + )! ); } const baseQuery = db - .select() + .select({ + redirectId: redirects.redirectId, + orgId: redirects.orgId, + niceId: redirects.niceId, + name: redirects.name, + sourcePath: redirects.sourcePath, + destinationUrl: redirects.destinationUrl, + permanent: redirects.permanent, + enabled: redirects.enabled, + resourceId: redirects.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId, + resourceFullDomain: resources.fullDomain, + 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( diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index a386e6570..684edd2ff 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -1,3 +1,9 @@ +import RedirectsTable from "@app/components/RedirectsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListRedirectsResponse } from "@server/routers/redirect"; +import type { AxiosResponse } from "axios"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -7,12 +13,65 @@ export const metadata: Metadata = { type RedirectIndexPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; + export const dynamic = "force-dynamic"; -export default async function ApiKeysPage(props: RedirectIndexPageProps) { - const params = await props.params; +export default async function RedirectIndexPage(props: RedirectIndexPageProps) { + const { orgId } = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); const t = await getTranslations(); - return null; + 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()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + redirects = responseData.redirects; + pagination = responseData.pagination; + } catch { + // empty list on error + } + + return ( + <> + + + ({ + redirectId: redirect.redirectId, + niceId: redirect.niceId, + name: redirect.name, + sourcePath: redirect.sourcePath, + destinationUrl: redirect.destinationUrl, + permanent: redirect.permanent, + enabled: redirect.enabled, + resourceId: redirect.resourceId, + resourceName: redirect.resourceName, + resourceNiceId: redirect.resourceNiceId, + resourceFullDomain: redirect.resourceFullDomain, + domainId: redirect.domainId, + baseDomain: redirect.baseDomain + }))} + rowCount={pagination.total} + pagination={{ + pageIndex: pagination.page - 1, + pageSize: pagination.pageSize + }} + /> + + ); } diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx new file mode 100644 index 000000000..163e62801 --- /dev/null +++ b/src/components/RedirectsTable.tsx @@ -0,0 +1,367 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; +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 type { PaginationState } from "@tanstack/react-table"; +import { ArrowRight, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; + +export type RedirectRow = { + redirectId: number; + niceId: string; + name: string; + sourcePath: string; + destinationUrl: string | null; + permanent: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + resourceFullDomain: string | null; + domainId: string | null; + baseDomain: string | null; +}; + +type RedirectsTableProps = { + redirects: RedirectRow[]; + orgId: string; + pagination: PaginationState; + rowCount: number; +}; + +export default function RedirectsTable({ + redirects, + orgId, + pagination, + rowCount +}: RedirectsTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [rows, setRows] = useState(redirects); + const [selected, setSelected] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + + useEffect(() => { + setRows(redirects); + }, [redirects]); + + 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); + + async function toggleEnabled(row: RedirectRow, enabled: boolean) { + setRows((prev) => + prev.map((r) => + r.redirectId === row.redirectId ? { ...r, enabled } : r + ) + ); + + try { + await api.post(`/org/${orgId}/redirects/${row.redirectId}`, { + enabled + }); + toast({ + title: t("success"), + description: t("redirectUpdated") + }); + router.refresh(); + } catch (e) { + setRows((prev) => + prev.map((r) => + r.redirectId === row.redirectId + ? { ...r, enabled: row.enabled } + : r + ) + ); + 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}`); + setRows((prev) => + prev.filter((r) => r.redirectId !== 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, + header: () => {t("name")}, + cell: ({ row }) => ( + + {row.original.name} + + ) + }, + { + id: "source", + friendlyName: t("redirectSource"), + header: () => ( + {t("redirectSource")} + ), + cell: ({ row }) => { + const redirect = row.original; + const host = + redirect.resourceFullDomain ?? redirect.baseDomain; + + if (!host) { + return ( + + {redirect.sourcePath} + + ); + } + + return ( + + {host} + {redirect.sourcePath} + + ); + } + }, + { + id: "attachedTo", + friendlyName: t("redirectAttachedTo"), + header: () => ( + {t("redirectAttachedTo")} + ), + cell: ({ row }) => { + const redirect = row.original; + + if (redirect.resourceId && redirect.resourceNiceId) { + return ( + + {redirect.resourceName} + + ); + } + + if (redirect.baseDomain) { + return {redirect.baseDomain}; + } + + return -; + } + }, + { + accessorKey: "destinationUrl", + friendlyName: t("redirectDestination"), + header: () => ( + {t("redirectDestination")} + ), + cell: ({ row }) => ( + + {row.original.destinationUrl ?? "-"} + + ) + }, + { + accessorKey: "permanent", + friendlyName: t("redirectType"), + header: () => {t("redirectType")}, + cell: ({ row }) => ( + + {row.original.permanent + ? t("redirectTypePermanent") + : t("redirectTypeTemporary")} + + ) + }, + { + accessorKey: "enabled", + friendlyName: t("enabled"), + header: () => {t("enabled")}, + cell: ({ row }) => ( + + toggleEnabled(row.original, checked) + } + /> + ) + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => ( +
+ + + + + + + + {t("edit")} + + + { + setSelected(row.original); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ) + } + ], + [orgId, t] + ); + + 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={{ + attachedTo: false + }} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + ); +} From 9e85b39fd52361e934b97e3ecc2e44a34fc02c0e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 10 Sep 2026 20:08:47 +0200 Subject: [PATCH 012/107] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 30 +- server/db/pg/schema/schema.ts | 15 +- server/db/sqlite/schema/schema.ts | 14 +- server/routers/redirect/createRedirect.ts | 42 +- server/routers/redirect/getRedirect.ts | 55 +- server/routers/redirect/listRedirects.ts | 18 +- server/routers/redirect/updateRedirect.ts | 29 +- server/routers/redirect/validation.ts | 17 +- .../settings/redirects/[niceId]/page.tsx | 73 ++ .../settings/redirects/create/page.tsx | 39 + src/app/[orgId]/settings/redirects/page.tsx | 7 +- src/components/RedirectForm.tsx | 777 ++++++++++++++++++ src/components/RedirectsTable.tsx | 90 +- src/components/Settings.tsx | 2 +- 14 files changed, 1147 insertions(+), 61 deletions(-) create mode 100644 src/app/[orgId]/settings/redirects/[niceId]/page.tsx create mode 100644 src/app/[orgId]/settings/redirects/create/page.tsx create mode 100644 src/components/RedirectForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e741c9b56..2e925ad14 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4371,7 +4371,6 @@ "redirectAdd": "Add Redirect", "redirectSource": "Source", "redirectAttachedTo": "Attached To", - "redirectDestination": "Destination", "redirectType": "Type", "redirectTypePermanent": "Permanent (301)", "redirectTypeTemporary": "Temporary (302)", @@ -4382,5 +4381,32 @@ "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." + "redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded.", + "redirectDestinationDomain": "Destination Domain", + "redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com", + "redirectDestinationDomainRequired": "Enter a destination domain", + "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", + "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 Settings", + "selectedRedirectDomain": "Selected Domain", + "selectedRedirectResource": "Selected Resource", + "redirectSettingsGeneralDescription": "Configure the basic redirect settings", + "redirectSettingsDescription": "Configure where requests come from and where they are sent", + "redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it", + "redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource", + "redirectAttachDomain": "Domain", + "redirectAttachResource": "Resource", + "redirectDomainSelect": "Select a domain", + "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.", + "redirectDangerSectionDescription": "Permanently remove this redirect. This cannot be undone." } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 38c617e0d..4811400e6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -242,8 +242,19 @@ export const redirects = pgTable("redirects", { }), niceId: text("niceId").notNull(), name: varchar("name").notNull(), - sourcePath: varchar("sourcePath").notNull(), - destinationUrl: varchar("destinationUrl"), + subdomain: varchar("subdomain"), + destinationDomain: varchar("destinationDomain").notNull(), + pathMatchType: varchar("pathMatchType") + .$type<"exact" | "prefix" | "regex">() + .notNull() + .default("regex"), // exact, prefix, regex + matchPath: varchar("matchPath").notNull().default("*"), + 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 + permanent: boolean("permanent").notNull().default(false), enabled: boolean("enabled").notNull().default(true) }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5fbabdb88..f7f4884b9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -258,8 +258,18 @@ export const redirects = sqliteTable("redirects", { }), niceId: text("niceId").notNull(), name: text("name").notNull(), - sourcePath: text("sourcePath").notNull(), - destinationUrl: text("destinationUrl"), + destinationDomain: text("destinationDomain").notNull(), + pathMatchType: text("pathMatchType") + .$type<"exact" | "prefix" | "regex">() + .notNull() + .default("regex"), // exact, prefix, regex + matchPath: text("matchPath").notNull().default("*"), + 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 + permanent: integer("permanent", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index c1c94b9ad..d8da0040d 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -9,7 +9,12 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; -import { redirectSourcePathSchema } from "@server/routers/redirect/validation"; +import { + redirectMatchPathSchema, + redirectPathMatchTypeSchema, + redirectRewritePathSchema, + redirectRewritePathTypeSchema +} from "@server/routers/redirect/validation"; import { getUniqueRedirectName } from "@server/db/names"; export type CreateRedirectResponse = { @@ -24,11 +29,26 @@ const bodySchema = z.strictObject({ name: z.string().nonempty(), resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), - sourcePath: redirectSourcePathSchema, - destinationUrl: z.url().optional().nullable(), + destinationDomain: z.string().nonempty(), + pathMatchType: redirectPathMatchTypeSchema.optional(), + matchPath: redirectMatchPathSchema, + rewritePath: redirectRewritePathSchema.optional().nullable(), + rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), permanent: 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"] + } +); registry.registerPath({ method: "put", @@ -83,8 +103,11 @@ export async function createRedirect( name, resourceId, domainId, - sourcePath, - destinationUrl, + destinationDomain, + pathMatchType, + matchPath, + rewritePath, + rewritePathType, permanent, enabled } = parsedBody.data; @@ -147,8 +170,11 @@ export async function createRedirect( niceId, resourceId: resourceId ?? null, domainId: domainId ?? null, - sourcePath, - destinationUrl: destinationUrl ?? null, + destinationDomain, + pathMatchType: pathMatchType ?? "regex", + matchPath, + rewritePath: rewritePath ?? null, + rewritePathType: rewritePathType ?? null, permanent: permanent ?? false, enabled: enabled ?? true }) diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index 979bf6035..03f7aa186 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -1,7 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { redirects, db } from "@server/db"; -import type { Redirect } from "@server/db"; +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"; @@ -12,7 +11,49 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; export type GetRedirectResponse = { - redirect: Redirect; + redirect: { + redirectId: number; + orgId: string; + niceId: string; + name: string; + destinationDomain: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; + permanent: boolean; + enabled: boolean; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: 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, + destinationDomain: redirects.destinationDomain, + pathMatchType: redirects.pathMatchType, + matchPath: redirects.matchPath, + rewritePath: redirects.rewritePath, + rewritePathType: redirects.rewritePathType, + permanent: redirects.permanent, + 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 }; const paramsSchema = z.strictObject({ @@ -29,8 +70,10 @@ const paramsSchema = z.strictObject({ async function query(orgId: string, redirectId?: number, niceId?: string) { if (redirectId) { const [res] = await db - .select() + .select(redirectColumns) .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin(domains, eq(domains.domainId, redirects.domainId)) .where( and( eq(redirects.redirectId, redirectId), @@ -41,8 +84,10 @@ async function query(orgId: string, redirectId?: number, niceId?: string) { return res; } else if (niceId) { const [res] = await db - .select() + .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)) ) diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 28163d17f..d5984f53e 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -16,8 +16,11 @@ export type ListRedirectsResponse = PaginatedResponse<{ orgId: string; niceId: string; name: string; - sourcePath: string; - destinationUrl: string | null; + destinationDomain: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; permanent: boolean; enabled: boolean; resourceId: number | null; @@ -122,8 +125,8 @@ export async function listRedirects( conditions.push( or( like(sql`LOWER(${redirects.name})`, term), - like(sql`LOWER(${redirects.sourcePath})`, term), - like(sql`LOWER(${redirects.destinationUrl})`, term) + like(sql`LOWER(${redirects.matchPath})`, term), + like(sql`LOWER(${redirects.destinationDomain})`, term) )! ); } @@ -134,8 +137,11 @@ export async function listRedirects( orgId: redirects.orgId, niceId: redirects.niceId, name: redirects.name, - sourcePath: redirects.sourcePath, - destinationUrl: redirects.destinationUrl, + destinationDomain: redirects.destinationDomain, + pathMatchType: redirects.pathMatchType, + matchPath: redirects.matchPath, + rewritePath: redirects.rewritePath, + rewritePathType: redirects.rewritePathType, permanent: redirects.permanent, enabled: redirects.enabled, resourceId: redirects.resourceId, diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 7f10ae69f..9158fd270 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -11,7 +11,10 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import { redirectNiceIdSchema, - redirectSourcePathSchema + redirectMatchPathSchema, + redirectPathMatchTypeSchema, + redirectRewritePathSchema, + redirectRewritePathTypeSchema } from "@server/routers/redirect/validation"; export type UpdateRedirectResponse = { @@ -28,8 +31,11 @@ const bodySchema = z.strictObject({ niceId: redirectNiceIdSchema.optional(), resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), - sourcePath: redirectSourcePathSchema.optional(), - destinationUrl: z.url().optional().nullable(), + destinationDomain: z.string().nonempty().optional(), + pathMatchType: redirectPathMatchTypeSchema.optional(), + matchPath: redirectMatchPathSchema.optional(), + rewritePath: redirectRewritePathSchema.optional().nullable(), + rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), permanent: z.boolean().optional(), enabled: z.boolean().optional() }); @@ -190,11 +196,20 @@ export async function updateRedirect( if (body.domainId !== undefined) { updateData.domainId = body.domainId; } - if (body.sourcePath !== undefined) { - updateData.sourcePath = body.sourcePath; + if (body.destinationDomain !== undefined) { + updateData.destinationDomain = body.destinationDomain; } - if (body.destinationUrl !== undefined) { - updateData.destinationUrl = body.destinationUrl; + 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.permanent !== undefined) { updateData.permanent = body.permanent; diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index 49fe256bd..aa264222d 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -9,8 +9,15 @@ export const redirectNiceIdSchema = z "niceId can only contain letters, numbers, and dashes" ); -export const redirectSourcePathSchema = z - .string() - .nonempty() - .regex(/^\//, "sourcePath must start with a /") - .default("/*"); +export const redirectPathMatchTypeSchema = z.enum(["exact", "prefix", "regex"]); + +export const redirectRewritePathTypeSchema = z.enum([ + "exact", + "prefix", + "regex", + "stripPrefix" +]); + +export const redirectMatchPathSchema = z.string().nonempty().default("*"); + +export const redirectRewritePathSchema = z.string().nonempty(); 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..8ceb1fc9e --- /dev/null +++ b/src/app/[orgId]/settings/redirects/[niceId]/page.tsx @@ -0,0 +1,73 @@ +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 + ? { + 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 index 684edd2ff..f2113c4e1 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -55,8 +55,11 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { redirectId: redirect.redirectId, niceId: redirect.niceId, name: redirect.name, - sourcePath: redirect.sourcePath, - destinationUrl: redirect.destinationUrl, + destinationDomain: redirect.destinationDomain, + pathMatchType: redirect.pathMatchType, + matchPath: redirect.matchPath, + rewritePath: redirect.rewritePath, + rewritePathType: redirect.rewritePathType, permanent: redirect.permanent, enabled: redirect.enabled, resourceId: redirect.resourceId, diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx new file mode 100644 index 000000000..fed65b713 --- /dev/null +++ b/src/components/RedirectForm.tsx @@ -0,0 +1,777 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + 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 { orgQueries } from "@app/lib/queries"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { + CreateRedirectResponse, + GetRedirectResponse +} from "@server/routers/redirect"; +import type { AxiosResponse } from "axios"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ResourceSelector, type SelectedResource } from "./resource-selector"; +import { + PathMatchDisplay, + PathMatchModal, + PathRewriteDisplay, + PathRewriteModal +} from "@app/components/PathMatchRenameModal"; +import { Plus } from "lucide-react"; +import Link from "next/link"; + +const DEFAULT_MATCH_PATH = "*"; +const DEFAULT_PATH_MATCH_TYPE = "regex" as const; + +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 [saveLoading, setSaveLoading] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedResource, setSelectedResource] = + useState(initialResource); + + const { data: domains = [] } = useQuery(orgQueries.domains({ orgId })); + + const formSchema = useMemo( + () => + z + .object({ + name: z + .string() + .trim() + .min(1, { message: t("nameRequired") }), + attachTo: z.enum(["domain", "resource"]), + domainId: z.string().nullable(), + resourceId: z.number().int().positive().nullable(), + destinationDomain: z + .string() + .trim() + .min(1, { + message: t("redirectDestinationDomainRequired") + }), + pathMatchType: z.enum(["exact", "prefix", "regex"]), + matchPath: z.string().trim().min(1), + rewritePath: z.string().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .nullable(), + permanent: 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"] + }); + } + // 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, + resourceId: redirect?.resourceId ?? null, + destinationDomain: redirect?.destinationDomain ?? "", + pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE, + matchPath: redirect?.matchPath ?? DEFAULT_MATCH_PATH, + rewritePath: redirect?.rewritePath ?? null, + rewritePathType: redirect?.rewritePathType ?? null, + permanent: redirect?.permanent ?? false, + enabled: redirect?.enabled ?? true + } + }); + + const attachTo = form.watch("attachTo"); + 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(values: RedirectFormValues) { + setSaveLoading(true); + + // 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, + resourceId: + values.attachTo === "resource" ? values.resourceId : null, + destinationDomain: values.destinationDomain.trim(), + pathMatchType: values.pathMatchType, + matchPath: values.matchPath.trim(), + rewritePath: values.rewritePath?.trim() || null, + rewritePathType: values.rewritePathType, + permanent: values.permanent, + 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") + }); + router.push( + `/${orgId}/settings/redirects/${res.data.data.redirect.niceId}` + ); + } + } catch (e) { + toast({ + variant: "destructive", + title: isEditing + ? t("redirectErrorUpdate") + : t("redirectErrorCreate"), + description: formatAxiosError( + e, + isEditing + ? t("redirectErrorUpdate") + : t("redirectErrorCreate") + ) + }); + } finally { + setSaveLoading(false); + } + } + + async function onDelete() { + setDeleteLoading(true); + try { + await api.delete(`/org/${orgId}/redirects/${redirect!.redirectId}`); + toast({ + title: t("success"), + description: t("redirectDeleted") + }); + router.push(`/${orgId}/settings/redirects`); + } catch (e) { + toast({ + variant: "destructive", + title: t("redirectErrorDelete"), + description: formatAxiosError(e, t("redirectErrorDelete")) + }); + } finally { + setDeleteLoading(false); + setIsDeleteModalOpen(false); + } + } + + return ( + <> + {isEditing && ( + +

{t("redirectQuestionRemove")}

+

{t("redirectMessageRemove")}

+ + } + buttonText={t("redirectDeleteConfirm")} + onConfirm={onDelete} + string={redirect!.name} + title={t("redirectDelete")} + /> + )} + + + + + + {t("general")} + + + {t("redirectSettingsGeneralDescription")} + + + + + +
+ + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t( + "redirectAttachedTo" + )} + + + + {t( + "redirectAttachedToDescription" + )} + + + + )} + /> + + {attachTo === "domain" ? ( + + ( + + + {t( + "selectedRedirectDomain" + )} + + + + + )} + /> + + ) : ( + + ( + + + {t( + "selectedRedirectResource" + )} + + + + + + + + + { + setSelectedResource( + resource + ); + field.onChange( + resource.resourceId + ); + }} + /> + + + + + )} + /> + + )} + +
+ +
+
+
+ + + + + {t("redirectSettings")} + + + {t("redirectSettingsDescription")} + + + + + +
+ + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t( + "redirectDestinationDomain" + )} + + + + + + {t( + "redirectDestinationDomainDescription" + )} + + + + )} + /> + + + + ( + + + {t("matchPath")} + + { + // matchPath and + // pathMatchType are + // NOT NULL, so a + // clear falls back + // to the defaults + // rather than null. + field.onChange( + config.path || + DEFAULT_MATCH_PATH + ); + form.setValue( + "pathMatchType", + (config.pathMatchType as + | "exact" + | "prefix" + | "regex") || + DEFAULT_PATH_MATCH_TYPE + ); + }} + trigger={ + + } + /> + + {t( + "redirectMatchPathDescription" + )} + + + + )} + /> + + + + ( + + + {t("rewritePath")} + + { + field.onChange( + config.rewritePath || + null + ); + form.setValue( + "rewritePathType", + (config.rewritePathType as + | "exact" + | "prefix" + | "regex" + | "stripPrefix" + | null) ?? + null + ); + }} + trigger={ + hasRewrite ? ( + + ) : ( + + ) + } + /> + + {t( + "redirectRewritePathDescription" + )} + + + + )} + /> + + + + ( + + + + + + + )} + /> + + +
+ +
+
+
+ + {isEditing && ( + + + + {t("dangerSection")} + + + {t("redirectDangerSectionDescription")} + + + + + + + )} + +
+ + +
+
+ + ); +} diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 163e62801..f4ae50c88 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -30,8 +30,11 @@ export type RedirectRow = { redirectId: number; niceId: string; name: string; - sourcePath: string; - destinationUrl: string | null; + destinationDomain: string; + pathMatchType: "exact" | "prefix" | "regex"; + matchPath: string; + rewritePath: string | null; + rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; permanent: boolean; enabled: boolean; resourceId: number | null; @@ -100,6 +103,24 @@ export default function RedirectsTable({ filter({ searchParams }); }, 300); + function matchTypeLabel(type: RedirectRow["pathMatchType"]) { + return { + prefix: t("pathMatchPrefix"), + exact: t("pathMatchExact"), + regex: t("pathMatchRegex") + }[type]; + } + + function rewriteTypeLabel(type: RedirectRow["rewritePathType"]) { + if (!type) return ""; + return { + prefix: t("pathRewritePrefix"), + exact: t("pathRewriteExact"), + regex: t("pathRewriteRegex"), + stripPrefix: t("pathRewriteStrip") + }[type]; + } + async function toggleEnabled(row: RedirectRow, enabled: boolean) { setRows((prev) => prev.map((r) => @@ -182,19 +203,16 @@ export default function RedirectsTable({ const host = redirect.resourceFullDomain ?? redirect.baseDomain; - if (!host) { - return ( - - {redirect.sourcePath} - - ); - } - return ( - - {host} - {redirect.sourcePath} - +
+ + {matchTypeLabel(redirect.pathMatchType)} + + + {host ?? ""} + {redirect.matchPath} + +
); } }, @@ -226,17 +244,46 @@ export default function RedirectsTable({ } }, { - accessorKey: "destinationUrl", - friendlyName: t("redirectDestination"), + accessorKey: "destinationDomain", + friendlyName: t("redirectDestinationDomain"), header: () => ( - {t("redirectDestination")} + + {t("redirectDestinationDomain")} + ), cell: ({ row }) => ( - - {row.original.destinationUrl ?? "-"} - + + {row.original.destinationDomain} + ) }, + { + id: "rewritePath", + accessorKey: "rewritePath", + friendlyName: t("rewritePath"), + header: () => {t("rewritePath")}, + cell: ({ row }) => { + const redirect = row.original; + const hasRewrite = + Boolean(redirect.rewritePath) || + redirect.rewritePathType === "stripPrefix"; + + if (!hasRewrite) { + return -; + } + + return ( +
+ + {rewriteTypeLabel(redirect.rewritePathType)} + + + {redirect.rewritePath ?? ""} + +
+ ); + } + }, { accessorKey: "permanent", friendlyName: t("redirectType"), @@ -356,7 +403,8 @@ export default function RedirectsTable({ isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ - attachedTo: false + attachedTo: false, + rewritePath: false }} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 892766c6d..ec3c99809 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -77,7 +77,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 )} > From 7ce2668b5bd750cc18011e81fddf63c4d4da1173 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 10 Sep 2026 22:05:45 +0200 Subject: [PATCH 013/107] =?UTF-8?q?=F0=9F=9A=A7=20redirect=20create=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 +- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 1 + server/routers/redirect/createRedirect.ts | 3 + server/routers/redirect/getRedirect.ts | 2 + server/routers/redirect/listRedirects.ts | 2 + server/routers/redirect/updateRedirect.ts | 4 + src/app/[orgId]/settings/redirects/page.tsx | 1 + src/components/RedirectForm.tsx | 170 +++++++++++--------- src/components/RedirectsTable.tsx | 19 ++- src/components/Settings.tsx | 82 ++++++++-- 11 files changed, 192 insertions(+), 96 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2e925ad14..8e42ca361 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4397,13 +4397,13 @@ "redirectSettings": "Redirect Settings", "selectedRedirectDomain": "Selected Domain", "selectedRedirectResource": "Selected Resource", + "redirectResourceNoDomain": "This resource has no domain", "redirectSettingsGeneralDescription": "Configure the basic redirect settings", "redirectSettingsDescription": "Configure where requests come from and where they are sent", "redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it", "redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource", "redirectAttachDomain": "Domain", "redirectAttachResource": "Resource", - "redirectDomainSelect": "Select a domain", "redirectDomainRequired": "Select a domain to attach this redirect to", "redirectResourceRequired": "Select a resource to attach this redirect to", "redirectPermanent": "Permanent Redirect", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4811400e6..e3517a533 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -248,7 +248,7 @@ export const redirects = pgTable("redirects", { .$type<"exact" | "prefix" | "regex">() .notNull() .default("regex"), // exact, prefix, regex - matchPath: varchar("matchPath").notNull().default("*"), + matchPath: varchar("matchPath").notNull().default(".*"), rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value, // else, the original path will be kept rewritePathType: varchar("rewritePathType").$type< diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f7f4884b9..b29a83f71 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -258,6 +258,7 @@ export const redirects = sqliteTable("redirects", { }), niceId: text("niceId").notNull(), name: text("name").notNull(), + subdomain: text("subdomain"), destinationDomain: text("destinationDomain").notNull(), pathMatchType: text("pathMatchType") .$type<"exact" | "prefix" | "regex">() diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index d8da0040d..a5b77a913 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -29,6 +29,7 @@ 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(), destinationDomain: z.string().nonempty(), pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema, @@ -103,6 +104,7 @@ export async function createRedirect( name, resourceId, domainId, + subdomain, destinationDomain, pathMatchType, matchPath, @@ -170,6 +172,7 @@ export async function createRedirect( niceId, resourceId: resourceId ?? null, domainId: domainId ?? null, + subdomain: subdomain ?? null, destinationDomain, pathMatchType: pathMatchType ?? "regex", matchPath, diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index 03f7aa186..cba2048e9 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -16,6 +16,7 @@ export type GetRedirectResponse = { orgId: string; niceId: string; name: string; + subdomain: string | null; destinationDomain: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string; @@ -39,6 +40,7 @@ const redirectColumns = { orgId: redirects.orgId, niceId: redirects.niceId, name: redirects.name, + subdomain: redirects.subdomain, destinationDomain: redirects.destinationDomain, pathMatchType: redirects.pathMatchType, matchPath: redirects.matchPath, diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index d5984f53e..47c918728 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -16,6 +16,7 @@ export type ListRedirectsResponse = PaginatedResponse<{ orgId: string; niceId: string; name: string; + subdomain: string | null; destinationDomain: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string; @@ -137,6 +138,7 @@ export async function listRedirects( orgId: redirects.orgId, niceId: redirects.niceId, name: redirects.name, + subdomain: redirects.subdomain, destinationDomain: redirects.destinationDomain, pathMatchType: redirects.pathMatchType, matchPath: redirects.matchPath, diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 9158fd270..0dfd27722 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -31,6 +31,7 @@ const bodySchema = z.strictObject({ niceId: redirectNiceIdSchema.optional(), resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), + subdomain: z.string().nonempty().optional().nullable(), destinationDomain: z.string().nonempty().optional(), pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema.optional(), @@ -196,6 +197,9 @@ export async function updateRedirect( if (body.domainId !== undefined) { updateData.domainId = body.domainId; } + if (body.subdomain !== undefined) { + updateData.subdomain = body.subdomain; + } if (body.destinationDomain !== undefined) { updateData.destinationDomain = body.destinationDomain; } diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index f2113c4e1..5837b438e 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -55,6 +55,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { redirectId: redirect.redirectId, niceId: redirect.niceId, name: redirect.name, + subdomain: redirect.subdomain, destinationDomain: redirect.destinationDomain, pathMatchType: redirect.pathMatchType, matchPath: redirect.matchPath, diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index fed65b713..5a85e58f1 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -41,7 +41,6 @@ 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 { orgQueries } from "@app/lib/queries"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { zodResolver } from "@hookform/resolvers/zod"; import type { @@ -49,7 +48,6 @@ import type { GetRedirectResponse } from "@server/routers/redirect"; import type { AxiosResponse } from "axios"; -import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; @@ -63,9 +61,10 @@ import { PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { Plus } from "lucide-react"; +import DomainPicker from "@app/components/DomainPicker"; import Link from "next/link"; -const DEFAULT_MATCH_PATH = "*"; +const DEFAULT_MATCH_PATH = ".*"; const DEFAULT_PATH_MATCH_TYPE = "regex" as const; export type ExistingRedirect = GetRedirectResponse["redirect"]; @@ -95,8 +94,6 @@ export default function RedirectForm({ const [selectedResource, setSelectedResource] = useState(initialResource); - const { data: domains = [] } = useQuery(orgQueries.domains({ orgId })); - const formSchema = useMemo( () => z @@ -107,6 +104,7 @@ export default function RedirectForm({ .min(1, { message: t("nameRequired") }), attachTo: z.enum(["domain", "resource"]), domainId: z.string().nullable(), + subdomain: z.string().nullable(), resourceId: z.number().int().positive().nullable(), destinationDomain: z .string() @@ -163,6 +161,7 @@ export default function RedirectForm({ name: redirect?.name ?? "", attachTo: redirect?.resourceId ? "resource" : "domain", domainId: redirect?.domainId ?? null, + subdomain: redirect?.subdomain ?? null, resourceId: redirect?.resourceId ?? null, destinationDomain: redirect?.destinationDomain ?? "", pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE, @@ -190,6 +189,8 @@ export default function RedirectForm({ 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, destinationDomain: values.destinationDomain.trim(), @@ -283,7 +284,7 @@ export default function RedirectForm({ )} - + {t("general")} @@ -301,6 +302,35 @@ export default function RedirectForm({ id="redirect-form" > + + ( + + + + + + + )} + /> + + {attachTo === "domain" ? ( - + ( + render={() => ( - - {t( - "selectedRedirectDomain" - )} - - + onDomainChange={( + res + ) => { + form.setValue( + "domainId", + res?.domainId ?? + null, + { + shouldValidate: true + } + ); + form.setValue( + "subdomain", + res?.subdomain || + null + ); + }} + /> )} @@ -484,6 +499,33 @@ export default function RedirectForm({ /> )} + + {attachTo === "resource" && ( + + + + {t("resourceDomain")} + + + + + )} @@ -491,7 +533,7 @@ export default function RedirectForm({ - + {t("redirectSettings")} @@ -504,33 +546,9 @@ export default function RedirectForm({
- + - ( - - - {t("name")} - - - - - - - )} - /> - - - { const redirect = row.original; - const host = - redirect.resourceFullDomain ?? redirect.baseDomain; + // 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; return (
@@ -237,7 +244,13 @@ export default function RedirectsTable({ } if (redirect.baseDomain) { - return {redirect.baseDomain}; + return ( + + {[redirect.subdomain, redirect.baseDomain] + .filter(Boolean) + .join(".")} + + ); } return -; diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index ec3c99809..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({ @@ -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} +
+ ); } From 4324eebc1f61b16e8df9bc01d2baa06d5bc26cbd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 11 Sep 2026 21:25:20 +0200 Subject: [PATCH 014/107] =?UTF-8?q?=F0=9F=92=84=20Redirect=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 7 +- server/routers/redirect/createRedirect.ts | 3 +- server/routers/redirect/updateRedirect.ts | 3 +- server/routers/redirect/validation.ts | 8 ++ src/app/navigation.tsx | 15 ++- src/components/RedirectForm.tsx | 27 +++-- src/components/RedirectsTable.tsx | 133 +++++++++++++++------- 7 files changed, 131 insertions(+), 65 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 8e42ca361..c6d731e90 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4369,11 +4369,11 @@ "redirectsDescription": "Forward requests from a path on your domains or resources to another URL", "redirectsSearch": "Search redirects...", "redirectAdd": "Add Redirect", - "redirectSource": "Source", + "redirectSourceDomain": "Source Domain", "redirectAttachedTo": "Attached To", "redirectType": "Type", - "redirectTypePermanent": "Permanent (301)", - "redirectTypeTemporary": "Temporary (302)", + "redirectTypePermanent": "Permanent (308)", + "redirectTypeTemporary": "Temporary (307)", "redirectUpdated": "Redirect updated successfully", "redirectErrorUpdate": "Failed to update redirect", "redirectDeleted": "Redirect deleted successfully", @@ -4385,6 +4385,7 @@ "redirectDestinationDomain": "Destination Domain", "redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com", "redirectDestinationDomainRequired": "Enter a destination domain", + "redirectDestinationDomainInvalid": "Enter a valid domain, such as example.com", "redirectMatchPathDescription": "Which incoming paths this redirect applies to", "redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.", "redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix", diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index a5b77a913..d1ca209a3 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { + redirectDestinationDomainSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -30,7 +31,7 @@ const bodySchema = z.strictObject({ resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: z.string().nonempty(), + destinationDomain: redirectDestinationDomainSchema, pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema, rewritePath: redirectRewritePathSchema.optional().nullable(), diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 0dfd27722..0a6b53a3c 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -11,6 +11,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import { redirectNiceIdSchema, + redirectDestinationDomainSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -32,7 +33,7 @@ const bodySchema = z.strictObject({ resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: z.string().nonempty().optional(), + destinationDomain: redirectDestinationDomainSchema.optional(), pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema.optional(), rewritePath: redirectRewritePathSchema.optional().nullable(), diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index aa264222d..7c9122fab 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isValidDomain } from "@server/lib/validators"; export const redirectNiceIdSchema = z .string() @@ -21,3 +22,10 @@ export const redirectRewritePathTypeSchema = z.enum([ export const redirectMatchPathSchema = z.string().nonempty().default("*"); export const redirectRewritePathSchema = z.string().nonempty(); + +export const redirectDestinationDomainSchema = z + .string() + .nonempty() + .refine(isValidDomain, { + message: "Invalid domain" + }); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 820619cd8..9aba63d15 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,6 +141,11 @@ export const orgNavSections = ( href: "/{orgId}/settings/domains", icon: }, + { + title: "sidebarRedirects", + href: "/{orgId}/settings/redirects", + icon: + }, ...(build === "saas" ? [ { @@ -324,11 +329,6 @@ export const orgNavSections = ( href: "/{orgId}/settings/api-keys", icon: }, - { - title: "sidebarRedirects", - href: "/{orgId}/settings/redirects", - icon: - }, ...(!env?.flags.disableEnterpriseFeatures ? [ { @@ -467,6 +467,11 @@ export const commandBarNavSections = ( title: "commandMachineClients", icon: }, + { + title: "sidebarRedirects", + href: "/{orgId}/settings/redirects", + icon: + }, ...(build === "saas" ? [ { diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index 5a85e58f1..e9f5230d9 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -40,6 +40,7 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { isValidDomain } from "@server/lib/validators"; import { cn } from "@app/lib/cn"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -50,7 +51,7 @@ import type { import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ResourceSelector, type SelectedResource } from "./resource-selector"; @@ -88,7 +89,7 @@ export default function RedirectForm({ const router = useRouter(); const t = useTranslations(); - const [saveLoading, setSaveLoading] = useState(false); + const [, formAction, saveLoading] = useActionState(onSubmit, null); const [deleteLoading, setDeleteLoading] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = @@ -111,6 +112,9 @@ export default function RedirectForm({ .trim() .min(1, { message: t("redirectDestinationDomainRequired") + }) + .refine(isValidDomain, { + message: t("redirectDestinationDomainInvalid") }), pathMatchType: z.enum(["exact", "prefix", "regex"]), matchPath: z.string().trim().min(1), @@ -181,8 +185,10 @@ export default function RedirectForm({ const hasRewrite = Boolean(rewritePath) || rewritePathType === "stripPrefix"; - async function onSubmit(values: RedirectFormValues) { - setSaveLoading(true); + 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. @@ -221,9 +227,7 @@ export default function RedirectForm({ title: t("success"), description: t("redirectCreated") }); - router.push( - `/${orgId}/settings/redirects/${res.data.data.redirect.niceId}` - ); + router.push(`/${orgId}/settings/redirects/`); } } catch (e) { toast({ @@ -238,8 +242,6 @@ export default function RedirectForm({ : t("redirectErrorCreate") ) }); - } finally { - setSaveLoading(false); } } @@ -297,10 +299,7 @@ export default function RedirectForm({ - + - + ( - {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; - - return ( -
- - {matchTypeLabel(redirect.pathMatchType)} - - - {host ?? ""} - {redirect.matchPath} - -
- ); - } + id: "niceId", + accessorKey: "niceId", + friendlyName: t("identifier"), + header: () => {t("identifier")}, + cell: ({ row }) => ( + {row.original.niceId} + ) }, { id: "attachedTo", @@ -234,28 +219,94 @@ export default function RedirectsTable({ if (redirect.resourceId && redirect.resourceNiceId) { return ( - - {redirect.resourceName} - + + + {redirect.resourceName} + + + ); } - if (redirect.baseDomain) { + console.log({ + redirect + }); + + if (redirect.domainId) { return ( - - {[redirect.subdomain, redirect.baseDomain] - .filter(Boolean) - .join(".")} - + ); } return -; } }, + + { + id: "sourceDomain", + friendlyName: t("redirectSourceDomain"), + header: () => ( + {t("redirectSourceDomain")} + ), + 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; + + return host ? ( + {host} + ) : ( + - + ); + } + }, + { + id: "matchPath", + accessorKey: "matchPath", + friendlyName: t("matchPath"), + header: () => {t("matchPath")}, + cell: ({ row }) => { + const redirect = row.original; + return ( +
+ + {matchTypeLabel(redirect.pathMatchType)} + + + {redirect.matchPath} + +
+ ); + } + }, + { accessorKey: "destinationDomain", friendlyName: t("redirectDestinationDomain"), @@ -417,7 +468,7 @@ export default function RedirectsTable({ rowCount={rowCount} columnVisibility={{ attachedTo: false, - rewritePath: false + niceId: false }} enableColumnVisibility stickyLeftColumn="name" From 5a6c23e05a2a2ca86eaf174f5d3e048b484c23c1 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 11 Sep 2026 21:52:37 +0200 Subject: [PATCH 015/107] =?UTF-8?q?=F0=9F=92=84=20Some=20UI=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 5 +- src/components/RedirectForm.tsx | 58 ++++++++++++++++++- src/components/RedirectsTable.tsx | 96 +++++++------------------------ src/components/ui/input.tsx | 1 + 4 files changed, 83 insertions(+), 77 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index c6d731e90..6ea4a470f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4369,7 +4369,8 @@ "redirectsDescription": "Forward requests from a path on your domains or resources to another URL", "redirectsSearch": "Search redirects...", "redirectAdd": "Add Redirect", - "redirectSourceDomain": "Source Domain", + "redirectSource": "Source", + "redirectDestination": "Destination", "redirectAttachedTo": "Attached To", "redirectType": "Type", "redirectTypePermanent": "Permanent (308)", @@ -4385,6 +4386,8 @@ "redirectDestinationDomain": "Destination Domain", "redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com", "redirectDestinationDomainRequired": "Enter a destination domain", + "redirectSameDomainAsSource": "Same domain as source", + "redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path", "redirectDestinationDomainInvalid": "Enter a valid domain, such as example.com", "redirectMatchPathDescription": "Which incoming paths this redirect applies to", "redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.", diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index e9f5230d9..8ee7968a1 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -51,7 +51,7 @@ import type { import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useActionState, useMemo, useState } from "react"; +import { useActionState, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ResourceSelector, type SelectedResource } from "./resource-selector"; @@ -95,6 +95,16 @@ export default function RedirectForm({ 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 formSchema = useMemo( () => z @@ -178,6 +188,29 @@ export default function RedirectForm({ }); const attachTo = form.watch("attachTo"); + + const sourceFullDomain = + attachTo === "domain" + ? domainFullDomain + : (selectedResource?.fullDomain ?? null); + + // Mirror is UI-only state; on edit, infer it from whether the saved + // destination already equals the source host. + const [sameDomainAsSource, setSameDomainAsSource] = useState( + Boolean( + redirect && + sourceFullDomain && + redirect.destinationDomain === sourceFullDomain + ) + ); + + useEffect(() => { + if (sameDomainAsSource && sourceFullDomain) { + form.setValue("destinationDomain", sourceFullDomain, { + shouldValidate: true + }); + } + }, [sameDomainAsSource, sourceFullDomain, form]); const pathMatchType = form.watch("pathMatchType"); const rewritePath = form.watch("rewritePath"); const rewritePathType = form.watch("rewritePathType"); @@ -430,6 +463,10 @@ export default function RedirectForm({ res?.subdomain || null ); + setDomainFullDomain( + res?.fullDomain ?? + null + ); }} /> @@ -547,6 +584,22 @@ export default function RedirectForm({ + + + + diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 6c33a7457..2e80fdc12 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -110,24 +110,6 @@ export default function RedirectsTable({ filter({ searchParams }); }, 300); - function matchTypeLabel(type: RedirectRow["pathMatchType"]) { - return { - prefix: t("pathMatchPrefix"), - exact: t("pathMatchExact"), - regex: t("pathMatchRegex") - }[type]; - } - - function rewriteTypeLabel(type: RedirectRow["rewritePathType"]) { - if (!type) return ""; - return { - prefix: t("pathRewritePrefix"), - exact: t("pathRewriteExact"), - regex: t("pathRewriteRegex"), - stripPrefix: t("pathRewriteStrip") - }[type]; - } - async function toggleEnabled(row: RedirectRow, enabled: boolean) { setRows((prev) => prev.map((r) => @@ -264,10 +246,10 @@ export default function RedirectsTable({ }, { - id: "sourceDomain", - friendlyName: t("redirectSourceDomain"), + id: "source", + friendlyName: t("redirectSource"), header: () => ( - {t("redirectSourceDomain")} + {t("redirectSource")} ), cell: ({ row }) => { const redirect = row.original; @@ -280,71 +262,34 @@ export default function RedirectsTable({ : null; const host = redirect.resourceFullDomain ?? domainHost; - return host ? ( - {host} - ) : ( - - - ); - } - }, - { - id: "matchPath", - accessorKey: "matchPath", - friendlyName: t("matchPath"), - header: () => {t("matchPath")}, - cell: ({ row }) => { - const redirect = row.original; return ( -
- - {matchTypeLabel(redirect.pathMatchType)} - - + + {host ?? ""} + {redirect.matchPath} - -
+ + ); } }, - { + id: "destination", accessorKey: "destinationDomain", - friendlyName: t("redirectDestinationDomain"), + friendlyName: t("redirectDestination"), header: () => ( - - {t("redirectDestinationDomain")} - + {t("redirectDestination")} ), - cell: ({ row }) => ( - - {row.original.destinationDomain} - - ) - }, - { - id: "rewritePath", - accessorKey: "rewritePath", - friendlyName: t("rewritePath"), - header: () => {t("rewritePath")}, cell: ({ row }) => { const redirect = row.original; - const hasRewrite = - Boolean(redirect.rewritePath) || - redirect.rewritePathType === "stripPrefix"; - - if (!hasRewrite) { - return -; - } - return ( -
- - {rewriteTypeLabel(redirect.rewritePathType)} - - - {redirect.rewritePath ?? ""} - -
+ + {redirect.destinationDomain} + {redirect.rewritePath && ( + + {redirect.rewritePath} + + )} + ); } }, @@ -468,7 +413,8 @@ export default function RedirectsTable({ rowCount={rowCount} columnVisibility={{ attachedTo: false, - niceId: false + niceId: false, + permanent: false }} enableColumnVisibility stickyLeftColumn="name" 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 From f19b2c391f02e9950374d2572f0f14e7b0bc40ab Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 11 Sep 2026 22:56:35 +0200 Subject: [PATCH 016/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20order=20by=20id=20?= =?UTF-8?q?desc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/redirect/listRedirects.ts | 4 ++-- src/components/RedirectsTable.tsx | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 47c918728..d26609222 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -7,7 +7,7 @@ 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, eq, like, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import type { PaginatedResponse } from "@server/types/Pagination"; export type ListRedirectsResponse = PaginatedResponse<{ @@ -171,7 +171,7 @@ export async function listRedirects( baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) - .orderBy(asc(redirects.name)) + .orderBy(desc(redirects.redirectId)) ]); return response(res, { diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 2e80fdc12..628b3dbd9 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -110,6 +110,12 @@ export default function RedirectsTable({ filter({ searchParams }); }, 300); + // 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(row: RedirectRow, enabled: boolean) { setRows((prev) => prev.map((r) => @@ -218,10 +224,6 @@ export default function RedirectsTable({ ); } - console.log({ - redirect - }); - if (redirect.domainId) { return ( } /> diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index e0b2c2802..2ecc28205 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -31,7 +31,15 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState, useTransition } from "react"; +import { + useEffect, + useMemo, + useOptimistic, + useRef, + useState, + useTransition, + type ComponentRef +} from "react"; import { useDebouncedCallback } from "use-debounce"; export type RedirectRow = { @@ -41,7 +49,7 @@ export type RedirectRow = { subdomain: string | null; destinationDomain: string; pathMatchType: "exact" | "prefix" | "regex"; - matchPath: string; + matchPath: string | null; rewritePath: string | null; rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; permanent: boolean; @@ -121,15 +129,9 @@ export default function RedirectsTable({ return path.endsWith("/") ? `${path}*` : `${path}/*`; } - async function toggleEnabled(row: RedirectRow, enabled: boolean) { - setRows((prev) => - prev.map((r) => - r.redirectId === row.redirectId ? { ...r, enabled } : r - ) - ); - + async function toggleEnabled(enabled: boolean, redirectId: number) { try { - await api.post(`/org/${orgId}/redirects/${row.redirectId}`, { + await api.post(`/org/${orgId}/redirects/${redirectId}`, { enabled }); toast({ @@ -138,13 +140,6 @@ export default function RedirectsTable({ }); router.refresh(); } catch (e) { - setRows((prev) => - prev.map((r) => - r.redirectId === row.redirectId - ? { ...r, enabled: row.enabled } - : r - ) - ); toast({ variant: "destructive", title: t("redirectErrorUpdate"), @@ -288,11 +283,15 @@ export default function RedirectsTable({ ) : null} {host ?? ""} - - {redirect.pathMatchType === "prefix" - ? withPrefixGlob(redirect.matchPath) - : redirect.matchPath} - + {redirect.matchPath && ( + + {redirect.pathMatchType === "prefix" + ? withPrefixGlob( + redirect.matchPath + ) + : redirect.matchPath} + + )}
); @@ -338,11 +337,9 @@ export default function RedirectsTable({ friendlyName: t("enabled"), header: () => {t("enabled")}, cell: ({ row }) => ( - - toggleEnabled(row.original, checked) - } + ) }, @@ -451,3 +448,36 @@ export default function RedirectsTable({ ); } + +type RedirectEnabledFormProps = { + redirect: RedirectRow; + onToggleEnabled: (val: boolean, redirectId: number) => Promise; +}; + +function RedirectEnabledForm({ + redirect, + onToggleEnabled +}: RedirectEnabledFormProps) { + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic( + redirect.enabled + ); + + const formRef = useRef>(null); + + async function submitAction(formData: FormData) { + const newEnabled = !(formData.get("enabled") === "on"); + setOptimisticEnabled(newEnabled); + await onToggleEnabled(newEnabled, redirect.redirectId); + } + + return ( + + formRef.current?.requestSubmit()} + /> + + ); +} From 85adae063b006b9dcf28a8e2972458b19c0d8dde Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 17 Sep 2026 21:44:56 +0200 Subject: [PATCH 024/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20handle=20`priority?= =?UTF-8?q?`=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/routers/redirect/createRedirect.ts | 4 ++ server/routers/redirect/getRedirect.ts | 2 + server/routers/redirect/listRedirects.ts | 4 +- server/routers/redirect/updateRedirect.ts | 5 +++ server/routers/redirect/validation.ts | 3 ++ src/app/[orgId]/settings/redirects/page.tsx | 1 + src/components/RedirectForm.tsx | 42 +++++++++++++++++++++ src/components/RedirectsTable.tsx | 33 ++++++++++++++-- 9 files changed, 90 insertions(+), 5 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9d9f0633f..f9cdebe8c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4393,6 +4393,7 @@ "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", "redirectCreate": "Create Redirect", "redirectCreateDescription": "Forward requests matching a path to another URL", "redirectEditDescription": "Update how this redirect forwards incoming requests", diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index 1131243e2..8167428ef 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -15,6 +15,7 @@ import { redirectPathMatchTypeSchema, redirectRewritePathSchema, isValidMatchPath, + redirectPrioritySchema, redirectRewritePathTypeSchema } from "@server/routers/redirect/validation"; import { getUniqueRedirectName } from "@server/db/names"; @@ -39,6 +40,7 @@ const bodySchema = z matchPath: redirectMatchPathSchema.optional().nullable(), rewritePath: redirectRewritePathSchema.optional().nullable(), rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), + priority: redirectPrioritySchema.optional().nullable(), permanent: z.boolean().optional(), enabled: z.boolean().optional() }) @@ -123,6 +125,7 @@ export async function createRedirect( matchPath, rewritePath, rewritePathType, + priority, permanent, enabled } = parsedBody.data; @@ -195,6 +198,7 @@ export async function createRedirect( matchPath: matchPath ?? null, rewritePath: rewritePath ?? null, rewritePathType: rewritePathType ?? null, + priority: priority ?? 100, permanent: permanent ?? false, enabled: enabled ?? true }) diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index f12a4b0af..6cdea3d6f 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -22,6 +22,7 @@ export type GetRedirectResponse = { matchPath: string | null; rewritePath: string | null; rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; + priority: number | null; permanent: boolean; enabled: boolean; resourceId: number | null; @@ -46,6 +47,7 @@ const redirectColumns = { matchPath: redirects.matchPath, rewritePath: redirects.rewritePath, rewritePathType: redirects.rewritePathType, + priority: redirects.priority, permanent: redirects.permanent, enabled: redirects.enabled, resourceId: redirects.resourceId, diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index d90acd6f6..c4421c0f6 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -22,6 +22,7 @@ export type ListRedirectsResponse = PaginatedResponse<{ matchPath: string | null; rewritePath: string | null; rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; + priority: number | null; permanent: boolean; enabled: boolean; resourceId: number | null; @@ -145,6 +146,7 @@ export async function listRedirects( matchPath: redirects.matchPath, rewritePath: redirects.rewritePath, rewritePathType: redirects.rewritePathType, + priority: redirects.priority, permanent: redirects.permanent, enabled: redirects.enabled, resourceId: redirects.resourceId, @@ -173,7 +175,7 @@ export async function listRedirects( baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) - .orderBy(desc(redirects.redirectId)) + .orderBy(desc(redirects.priority), desc(redirects.redirectId)) ]); return response(res, { diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 139db0e46..2833ebd70 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -16,6 +16,7 @@ import { redirectPathMatchTypeSchema, redirectRewritePathSchema, redirectRewritePathTypeSchema, + redirectPrioritySchema, isValidMatchPath } from "@server/routers/redirect/validation"; import { createCertificate } from "../certificates"; @@ -40,6 +41,7 @@ const bodySchema = z.strictObject({ matchPath: redirectMatchPathSchema.optional().nullable(), rewritePath: redirectRewritePathSchema.optional().nullable(), rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), + priority: redirectPrioritySchema.optional(), permanent: z.boolean().optional(), enabled: z.boolean().optional() }); @@ -255,6 +257,9 @@ export async function updateRedirect( if (body.rewritePathType !== undefined) { updateData.rewritePathType = body.rewritePathType; } + if (body.priority !== undefined) { + updateData.priority = body.priority; + } if (body.permanent !== undefined) { updateData.permanent = body.permanent; } diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index 93129eaf8..a72910a04 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -45,6 +45,9 @@ export function isValidMatchPath( export const redirectRewritePathSchema = z.string().nonempty(); +// Same range as target priorities; 100 means "let the system order it". +export const redirectPrioritySchema = z.int().min(1).max(1000); + export const redirectDestinationDomainSchema = z .string() .nonempty() diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 99c3e4f5a..1533e4f16 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -54,6 +54,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { matchPath: redirect.matchPath, rewritePath: redirect.rewritePath, rewritePathType: redirect.rewritePathType, + priority: redirect.priority, permanent: redirect.permanent, enabled: redirect.enabled, resourceId: redirect.resourceId, diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index c024e560a..10340a898 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -67,6 +67,7 @@ import DomainPicker from "@app/components/DomainPicker"; import Link from "next/link"; const DEFAULT_PATH_MATCH_TYPE = "regex" as const; +const DEFAULT_PRIORITY = 100; export type ExistingRedirect = GetRedirectResponse["redirect"]; @@ -132,6 +133,11 @@ export default function RedirectForm({ 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(), enabled: z.boolean() }) @@ -193,6 +199,7 @@ export default function RedirectForm({ matchPath: redirect?.matchPath ?? null, rewritePath: redirect?.rewritePath ?? null, rewritePathType: redirect?.rewritePathType ?? null, + priority: redirect?.priority ?? DEFAULT_PRIORITY, permanent: redirect?.permanent ?? false, enabled: redirect?.enabled ?? true } @@ -248,6 +255,7 @@ export default function RedirectForm({ matchPath: values.matchPath?.trim() || null, rewritePath: values.rewritePath?.trim() || null, rewritePathType: values.rewritePathType, + priority: values.priority, permanent: values.permanent, enabled: values.enabled }; @@ -792,6 +800,40 @@ export default function RedirectForm({ /> + + ( + + + {t("priority")} + + + + field.onChange( + e.target + .valueAsNumber + ) + } + /> + + + {t( + "priorityDescription" + )} + + + + )} + /> + + {redirect.pathMatchType === "prefix" - ? withPrefixGlob( - redirect.matchPath - ) + ? withPrefixGlob(redirect.matchPath) : redirect.matchPath} )} @@ -320,6 +322,28 @@ export default function RedirectsTable({ ); } }, + { + accessorKey: "priority", + friendlyName: t("priority"), + header: () => {t("priority")}, + 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"), @@ -439,7 +463,8 @@ export default function RedirectsTable({ columnVisibility={{ attachedTo: false, niceId: false, - permanent: false + permanent: false, + priority: false }} enableColumnVisibility stickyLeftColumn="name" From 2f5e3eede630cf70b683fa666d2a2092e555b2b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 17 Sep 2026 21:48:05 +0200 Subject: [PATCH 025/107] =?UTF-8?q?=F0=9F=93=9D=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/components/RedirectForm.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index f9cdebe8c..01b88bc7f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4394,6 +4394,7 @@ "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. This competes with the priorities of resource targets on the same domain: a redirect only wins over a resource route when its priority is higher. 100 means automatic ordering (system decides).", "redirectCreate": "Create Redirect", "redirectCreateDescription": "Forward requests matching a path to another URL", "redirectEditDescription": "Update how this redirect forwards incoming requests", diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index 10340a898..9d0ec7211 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -825,7 +825,7 @@ export default function RedirectForm({ {t( - "priorityDescription" + "redirectPriorityDescription" )} From 2cb5cea48f4d9af48454cef7d6dc0c26b717bf71 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 18 Sep 2026 20:29:52 +0200 Subject: [PATCH 026/107] =?UTF-8?q?=E2=9C=A8=20handle=20redirect=20request?= =?UTF-8?q?=20in=20badger's=20`verifySession`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 +- server/db/queries/verifySessionQueries.ts | 69 ++++++++- server/lib/traefik/middleware.test.ts | 134 +++++++++++++++++ server/lib/traefik/middleware.ts | 72 +++++++++ server/lib/traefik/redirect.ts | 18 +-- server/lib/traefik/rule.test.ts | 138 ++++++++++++++++++ server/lib/traefik/rule.ts | 64 ++++++++ server/routers/badger/logRequestAudit.ts | 1 + server/routers/badger/verifySession.ts | 101 ++++++++++++- .../[orgId]/settings/logs/request/page.tsx | 3 + 10 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 server/lib/traefik/middleware.test.ts create mode 100644 server/lib/traefik/rule.test.ts diff --git a/messages/en-US.json b/messages/en-US.json index 01b88bc7f..e694f1009 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3540,6 +3540,7 @@ "validEmail": "Valid email", "validSSO": "Valid SSO", "validVirtualAPIKey": "Valid Virtual API Key", + "allowedRedirect": "Allowed Redirect", "view": "View", "configManaged": "Config Managed", "connectedClient": "Connected Client", @@ -4394,7 +4395,7 @@ "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. This competes with the priorities of resource targets on the same domain: a redirect only wins over a resource route when its priority is higher. 100 means automatic ordering (system decides).", + "redirectPriorityDescription": "Higher priority routes are evaluated first. Redirects are always evaluated before targets.", "redirectCreate": "Create Redirect", "redirectCreateDescription": "Forward requests matching a path to another URL", "redirectEditDescription": "Update how this redirect forwards incoming requests", diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index a2ae61046..7db986226 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, or, sql } from "drizzle-orm"; import logger from "@server/logger"; export type ResourceWithAuth = { @@ -53,11 +55,74 @@ export type ResourceWithAuth = { org: Org; }; +export type RedirectByHost = { + redirectId: number; + orgId: string; + matchPath: string | null; + pathMatchType: string; + destinationDomain: 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, + destinationDomain: redirects.destinationDomain, + 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), eq(resources.enabled, true)), + inArray(redirectHost, candidates) + ) + ) + .orderBy(desc(redirects.priority)); +} + /** * Get resource by domain with pincode and password information */ 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 index 8f1b81c7e..787b95509 100644 --- a/server/lib/traefik/redirect.ts +++ b/server/lib/traefik/redirect.ts @@ -3,7 +3,7 @@ import config from "@server/lib/config"; import { buildHostRule, appendPathMatch, - computeRoutePriority + computeRedirectPriority } from "@server/lib/traefik/rule"; export type RedirectRouteRow = { @@ -97,17 +97,11 @@ export function buildRedirectConfig(params: { redirect.pathMatchType ); - // A redirect attached to a resource must win over that resource's - // router at the same host/path specificity, so nudge derived - // priorities up by one. Explicit priorities are used as-is. - const hasExplicitPriority = - !!redirect.priority && redirect.priority !== 100; - const priority = - computeRoutePriority( - redirect.priority, - redirect.matchPath, - redirect.pathMatchType - ) + (hasExplicitPriority ? 0 : 1); + 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 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 5f9406db4..66cd6defc 100644 --- a/server/lib/traefik/rule.ts +++ b/server/lib/traefik/rule.ts @@ -40,6 +40,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. @@ -69,3 +111,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/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 f50cf8cdc..59dacede1 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 { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -67,6 +71,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(), @@ -108,6 +113,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; @@ -192,6 +200,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: | { @@ -199,9 +231,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; @@ -1007,6 +1037,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 request's scheme and query are + * kept, the host is swapped for the destination domain 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 `${request.scheme}://${redirect.destinationDomain}${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: false, + 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/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") }, From d06a261cd643044d5910b24f442b25c23ed88f10 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 18 Sep 2026 21:04:56 +0200 Subject: [PATCH 027/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/traefik/getTraefikConfig.ts | 83 +++++++++++--------------- 1 file changed, 34 insertions(+), 49 deletions(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 5b3392368..726d93ab4 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,56 +1,54 @@ import { db, - targetHealthCheck, domains, - aiProviders, - resourceAiProviders, - siteResources, - siteNetworks, exitNodes, - redirects + resources, + siteNetworks, + 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, + 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, 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 { 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"; @@ -132,18 +130,12 @@ export async function getTraefikConfig( // Domain cert resolver fields domainCertResolver: domains.certResolver, - preferWildcardCert: domains.preferWildcardCert, - - // redirects - redirectMatchPath: redirects.matchPath, - redirectPathMatchType: redirects.pathMatchType, - redirectPriority: redirects.priority + preferWildcardCert: domains.preferWildcardCert }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) .leftJoin(domains, eq(domains.domainId, resources.domainId)) - .leftJoin(redirects, eq(resources.resourceId, redirects.resourceId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -175,13 +167,6 @@ export async function getTraefikConfig( ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering - console.dir( - { - resourcesWithTargetsAndSites - }, - { depth: null } - ); - // Group by resource and include targets with their unique site data const resourcesMap = new Map(); From 57b80bf74e154817f7bdd6ca0fc2dc98b0592993 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 18 Sep 2026 23:38:46 +0200 Subject: [PATCH 028/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Add=20`ssl`=20fiel?= =?UTF-8?q?d=20to=20`redirects`=20and=20add=20traefik=20config=20to=20publ?= =?UTF-8?q?ic=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 +- server/db/pg/schema/schema.ts | 3 + server/db/sqlite/schema/schema.ts | 3 + server/lib/traefik/getTraefikConfig.ts | 94 ++++++++++++++++++- .../private/lib/traefik/getTraefikConfig.ts | 12 +-- server/routers/badger/verifySession.ts | 2 +- server/routers/redirect/createRedirect.ts | 10 ++ server/routers/redirect/getRedirect.ts | 2 + server/routers/redirect/listRedirects.ts | 2 + server/routers/redirect/updateRedirect.ts | 20 +++- server/routers/redirect/validation.ts | 10 ++ src/components/RedirectForm.tsx | 52 ++++++++++ 12 files changed, 200 insertions(+), 14 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e7bc42222..b41d06eb6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4452,5 +4452,7 @@ "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." + "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." } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 39be6213a..cdf6e9f29 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -256,6 +256,9 @@ export const redirects = pgTable("redirects", { >(), // 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) }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e39e9508e..0fff3b9f8 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -274,6 +274,9 @@ export const redirects = sqliteTable("redirects", { 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) }); diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 726d93ab4..3401a797b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -2,6 +2,7 @@ import { db, domains, exitNodes, + redirects, resources, siteNetworks, siteResources, @@ -45,6 +46,7 @@ import { buildTcpUdpLoadBalancerServers } from "./loadBalancer"; import { applyPathRewriteMiddleware } from "./middleware"; +import { buildRedirectConfig, RedirectRouteRow } from "./redirect"; import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule"; import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias"; import { TargetWithSite } from "./types"; @@ -326,12 +328,87 @@ 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), eq(resources.enabled, true)) + ) + ) + .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 {}; } @@ -518,6 +595,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/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 8743c93e5..6b8e0b97d 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -414,6 +414,7 @@ export async function getTraefikConfig( matchPath: redirects.matchPath, pathMatchType: redirects.pathMatchType, priority: redirects.priority, + ssl: redirects.ssl, // Resource (when attached to one) resourceId: resources.resourceId, resourceFullDomain: resources.fullDomain, @@ -473,8 +474,7 @@ export async function getTraefikConfig( ? !!row.resourceSubdomain : !!row.subdomain, wildcard: row.resourceWildcard, - // Domain-attached redirects always get a certificate on creation - ssl: attachedToResource ? !!row.resourceSsl : true, + ssl: attachedToResource ? !!row.resourceSsl : row.ssl, attachedTo: attachedToResource ? "resource" : "domain", matchPath: row.matchPath, pathMatchType: row.pathMatchType, @@ -484,14 +484,6 @@ export async function getTraefikConfig( }); } - console.dir( - { - redirectRoutes, - redirectRows - }, - { depth: null } - ); - // 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. diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index da3b18685..6c6f36342 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1091,7 +1091,7 @@ function buildRedirectUrl( function redirected(res: Response, redirectUrl: string, permanent: boolean) { const data = { data: { - valid: false, + valid: true, redirectUrl, redirectPermanent: permanent, pangolinVersion: APP_VERSION diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index 8167428ef..bbcb3ac56 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -15,6 +15,7 @@ import { redirectPathMatchTypeSchema, redirectRewritePathSchema, isValidMatchPath, + isAllowedSsl, redirectPrioritySchema, redirectRewritePathTypeSchema } from "@server/routers/redirect/validation"; @@ -42,6 +43,7 @@ const bodySchema = z rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), priority: redirectPrioritySchema.optional().nullable(), permanent: z.boolean().optional(), + ssl: z.boolean().optional(), enabled: z.boolean().optional() }) .refine( @@ -64,6 +66,10 @@ const bodySchema = z .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({ @@ -127,6 +133,7 @@ export async function createRedirect( rewritePathType, priority, permanent, + ssl, enabled } = parsedBody.data; @@ -200,6 +207,9 @@ export async function createRedirect( 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(); diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index 6cdea3d6f..a7454f7ca 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -24,6 +24,7 @@ export type GetRedirectResponse = { rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; priority: number | null; permanent: boolean; + ssl: boolean; enabled: boolean; resourceId: number | null; resourceName: string | null; @@ -49,6 +50,7 @@ const redirectColumns = { rewritePathType: redirects.rewritePathType, priority: redirects.priority, permanent: redirects.permanent, + ssl: redirects.ssl, enabled: redirects.enabled, resourceId: redirects.resourceId, resourceName: resources.name, diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index c4421c0f6..229e2bb66 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -24,6 +24,7 @@ export type ListRedirectsResponse = PaginatedResponse<{ rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; priority: number | null; permanent: boolean; + ssl: boolean; enabled: boolean; resourceId: number | null; resourceName: string | null; @@ -148,6 +149,7 @@ export async function listRedirects( rewritePathType: redirects.rewritePathType, priority: redirects.priority, permanent: redirects.permanent, + ssl: redirects.ssl, enabled: redirects.enabled, resourceId: redirects.resourceId, resourceName: resources.name, diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 2833ebd70..84afad2e5 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -17,7 +17,8 @@ import { redirectRewritePathSchema, redirectRewritePathTypeSchema, redirectPrioritySchema, - isValidMatchPath + isValidMatchPath, + isAllowedSsl } from "@server/routers/redirect/validation"; import { createCertificate } from "../certificates"; @@ -43,6 +44,7 @@ const bodySchema = z.strictObject({ rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), priority: redirectPrioritySchema.optional(), permanent: z.boolean().optional(), + ssl: z.boolean().optional(), enabled: z.boolean().optional() }); @@ -225,6 +227,15 @@ export async function updateRedirect( ); } + if (!isAllowedSsl(body.ssl)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "TLS cannot be disabled on this build" + ) + ); + } + const updateData: Partial = {}; if (body.name !== undefined) { @@ -263,6 +274,13 @@ export async function updateRedirect( 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; } diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index a72910a04..3f2d2e087 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { isValidDomain } from "@server/lib/validators"; +import { build } from "@server/build"; export const redirectNiceIdSchema = z .string() @@ -54,3 +55,12 @@ export const redirectDestinationDomainSchema = z .refine(isValidDomain, { message: "Invalid domain" }); + +/** + * 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/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index 9d0ec7211..a1c0a52bf 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -42,6 +42,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { isValidDomain } from "@server/lib/validators"; import { isValidRegex } from "@server/routers/redirect/validation"; +import { build } from "@server/build"; import { cn } from "@app/lib/cn"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -139,6 +140,7 @@ export default function RedirectForm({ .min(1, { message: t("redirectPriorityInvalid") }) .max(1000, { message: t("redirectPriorityInvalid") }), permanent: z.boolean(), + ssl: z.boolean(), enabled: z.boolean() }) .superRefine((data, ctx) => { @@ -201,6 +203,7 @@ export default function RedirectForm({ rewritePathType: redirect?.rewritePathType ?? null, priority: redirect?.priority ?? DEFAULT_PRIORITY, permanent: redirect?.permanent ?? false, + ssl: redirect?.ssl ?? true, enabled: redirect?.enabled ?? true } }); @@ -257,6 +260,8 @@ export default function RedirectForm({ 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 }; @@ -581,6 +586,53 @@ export default function RedirectForm({ )} + + {/* The cloud only serves HTTPS, so there is nothing to toggle there. */} + {build !== "saas" && ( + + ( + + + + + + + )} + /> + + )} From 97c91fbdc91875f14d988d6735613396b7e3d9eb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 18 Sep 2026 23:55:51 +0200 Subject: [PATCH 029/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20destinati?= =?UTF-8?q?onDomain=20to=20destinationHost=20to=20include=20the=20Scheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 8 ++-- server/db/pg/schema/schema.ts | 2 +- server/db/queries/verifySessionQueries.ts | 4 +- server/db/sqlite/schema/schema.ts | 2 +- server/routers/badger/verifySession.ts | 6 +-- server/routers/redirect/createRedirect.ts | 8 ++-- server/routers/redirect/getRedirect.ts | 4 +- server/routers/redirect/listRedirects.ts | 6 +-- server/routers/redirect/updateRedirect.ts | 8 ++-- server/routers/redirect/validation.ts | 15 +++++-- src/app/[orgId]/settings/redirects/page.tsx | 2 +- src/components/RedirectForm.tsx | 45 ++++++++++++--------- src/components/RedirectsTable.tsx | 6 +-- 13 files changed, 65 insertions(+), 51 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index b41d06eb6..b50b54134 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4421,12 +4421,12 @@ "redirectDeleteConfirm": "Confirm Delete Redirect", "redirectQuestionRemove": "Are you sure you want to remove this redirect?", "redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded.", - "redirectDestinationDomain": "Destination Domain", - "redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com", - "redirectDestinationDomainRequired": "Enter a destination domain", + "redirectDestinationHost": "Destination", + "redirectDestinationHostDescription": "Where requests are sent, including the scheme, such as https://example.com", + "redirectDestinationHostRequired": "Enter a destination", "redirectSameDomainAsSource": "Same domain as source", "redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path", - "redirectDestinationDomainInvalid": "Enter a valid domain, such as example.com", + "redirectDestinationHostInvalid": "Enter a valid destination with a scheme, such as https://example.com", "redirectMatchPathDescription": "Which incoming paths this redirect applies to", "redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.", "redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index cdf6e9f29..e07436aa3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -243,7 +243,7 @@ export const redirects = pgTable("redirects", { niceId: text("niceId").notNull(), name: varchar("name").notNull(), subdomain: varchar("subdomain"), - destinationDomain: varchar("destinationDomain").notNull(), + destinationHost: varchar("destinationHost").notNull(), // scheme://host[:port] pathMatchType: varchar("pathMatchType") .$type<"exact" | "prefix" | "regex">() .notNull() diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 7db986226..101e43722 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -60,7 +60,7 @@ export type RedirectByHost = { orgId: string; matchPath: string | null; pathMatchType: string; - destinationDomain: string; + destinationHost: string; rewritePath: string | null; rewritePathType: string | null; permanent: boolean; @@ -102,7 +102,7 @@ export async function getRedirectsByHost( orgId: redirects.orgId, matchPath: redirects.matchPath, pathMatchType: redirects.pathMatchType, - destinationDomain: redirects.destinationDomain, + destinationHost: redirects.destinationHost, rewritePath: redirects.rewritePath, rewritePathType: redirects.rewritePathType, permanent: redirects.permanent, diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0fff3b9f8..02f1faed6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -259,7 +259,7 @@ export const redirects = sqliteTable("redirects", { niceId: text("niceId").notNull(), name: text("name").notNull(), subdomain: text("subdomain"), - destinationDomain: text("destinationDomain").notNull(), + destinationHost: text("destinationHost").notNull(), // scheme://host[:port] pathMatchType: text("pathMatchType") .$type<"exact" | "prefix" | "regex">() .notNull() diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 6c6f36342..504eb9ac0 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1060,8 +1060,8 @@ async function findRedirect( } /** - * Destination for a configured redirect: the request's scheme and query are - * kept, the host is swapped for the destination domain and the path is run + * Destination for a configured redirect: the scheme and host come from the + * redirect's destination, the request's query is kept and the path is run * through the redirect's rewrite rules (if any). */ function buildRedirectUrl( @@ -1083,7 +1083,7 @@ function buildRedirectUrl( // originalRequestURL is validated as a URL, so this is only defensive } - return `${request.scheme}://${redirect.destinationDomain}${newPath}${search}`; + return `${redirect.destinationHost}${newPath}${search}`; } // Like a notAllowed login bounce, but the destination is the configured diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index bbcb3ac56..de682e740 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -10,7 +10,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { - redirectDestinationDomainSchema, + redirectDestinationHostSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -36,7 +36,7 @@ const bodySchema = z resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: redirectDestinationDomainSchema, + destinationHost: redirectDestinationHostSchema, pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema.optional().nullable(), rewritePath: redirectRewritePathSchema.optional().nullable(), @@ -126,7 +126,7 @@ export async function createRedirect( resourceId, domainId, subdomain, - destinationDomain, + destinationHost, pathMatchType, matchPath, rewritePath, @@ -200,7 +200,7 @@ export async function createRedirect( resourceId: resourceId ?? null, domainId: domainId ?? null, subdomain: subdomain ?? null, - destinationDomain, + destinationHost, pathMatchType: pathMatchType ?? "regex", matchPath: matchPath ?? null, rewritePath: rewritePath ?? null, diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index a7454f7ca..f2eae2961 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -17,7 +17,7 @@ export type GetRedirectResponse = { niceId: string; name: string; subdomain: string | null; - destinationDomain: string; + destinationHost: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string | null; rewritePath: string | null; @@ -43,7 +43,7 @@ const redirectColumns = { niceId: redirects.niceId, name: redirects.name, subdomain: redirects.subdomain, - destinationDomain: redirects.destinationDomain, + destinationHost: redirects.destinationHost, pathMatchType: redirects.pathMatchType, matchPath: redirects.matchPath, rewritePath: redirects.rewritePath, diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 229e2bb66..0b214f4b5 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -17,7 +17,7 @@ export type ListRedirectsResponse = PaginatedResponse<{ niceId: string; name: string; subdomain: string | null; - destinationDomain: string; + destinationHost: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string | null; rewritePath: string | null; @@ -130,7 +130,7 @@ export async function listRedirects( or( like(sql`LOWER(${redirects.name})`, term), like(sql`LOWER(${redirects.matchPath})`, term), - like(sql`LOWER(${redirects.destinationDomain})`, term) + like(sql`LOWER(${redirects.destinationHost})`, term) )! ); } @@ -142,7 +142,7 @@ export async function listRedirects( niceId: redirects.niceId, name: redirects.name, subdomain: redirects.subdomain, - destinationDomain: redirects.destinationDomain, + destinationHost: redirects.destinationHost, pathMatchType: redirects.pathMatchType, matchPath: redirects.matchPath, rewritePath: redirects.rewritePath, diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 84afad2e5..31038a74c 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -11,7 +11,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, ne } from "drizzle-orm"; import { redirectNiceIdSchema, - redirectDestinationDomainSchema, + redirectDestinationHostSchema, redirectMatchPathSchema, redirectPathMatchTypeSchema, redirectRewritePathSchema, @@ -37,7 +37,7 @@ const bodySchema = z.strictObject({ resourceId: z.number().int().positive().optional().nullable(), domainId: z.string().nonempty().optional().nullable(), subdomain: z.string().nonempty().optional().nullable(), - destinationDomain: redirectDestinationDomainSchema.optional(), + destinationHost: redirectDestinationHostSchema.optional(), pathMatchType: redirectPathMatchTypeSchema.optional(), matchPath: redirectMatchPathSchema.optional().nullable(), rewritePath: redirectRewritePathSchema.optional().nullable(), @@ -253,8 +253,8 @@ export async function updateRedirect( if (body.subdomain !== undefined) { updateData.subdomain = body.subdomain; } - if (body.destinationDomain !== undefined) { - updateData.destinationDomain = body.destinationDomain; + if (body.destinationHost !== undefined) { + updateData.destinationHost = body.destinationHost; } if (body.pathMatchType !== undefined) { updateData.pathMatchType = body.pathMatchType; diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index 3f2d2e087..324ddf6ff 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -49,11 +49,20 @@ export const redirectRewritePathSchema = z.string().nonempty(); // Same range as target priorities; 100 means "let the system order it". export const redirectPrioritySchema = z.int().min(1).max(1000); -export const redirectDestinationDomainSchema = z +/** + * A destination is `scheme://host[:port]` with no path, query or fragment; + * the request path (after any rewrite) is appended to it by badger. + */ +export function isValidDestinationHost(value: string): boolean { + const match = /^https?:\/\/([^/:?#]+)(:\d{1,5})?$/.exec(value); + return match !== null && isValidDomain(match[1]); +} + +export const redirectDestinationHostSchema = z .string() .nonempty() - .refine(isValidDomain, { - message: "Invalid domain" + .refine(isValidDestinationHost, { + message: "Invalid destination, expected scheme://host such as https://example.com" }); /** diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 1533e4f16..434b0d470 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -49,7 +49,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { niceId: redirect.niceId, name: redirect.name, subdomain: redirect.subdomain, - destinationDomain: redirect.destinationDomain, + destinationHost: redirect.destinationHost, pathMatchType: redirect.pathMatchType, matchPath: redirect.matchPath, rewritePath: redirect.rewritePath, diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index a1c0a52bf..dedb245e8 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -40,8 +40,10 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { isValidDomain } from "@server/lib/validators"; -import { isValidRegex } from "@server/routers/redirect/validation"; +import { + isValidDestinationHost, + isValidRegex +} from "@server/routers/redirect/validation"; import { build } from "@server/build"; import { cn } from "@app/lib/cn"; import { CaretSortIcon } from "@radix-ui/react-icons"; @@ -119,14 +121,14 @@ export default function RedirectForm({ domainId: z.string().nullable(), subdomain: z.string().nullable(), resourceId: z.number().int().positive().nullable(), - destinationDomain: z + destinationHost: z .string() .trim() .min(1, { - message: t("redirectDestinationDomainRequired") + message: t("redirectDestinationHostRequired") }) - .refine(isValidDomain, { - message: t("redirectDestinationDomainInvalid") + .refine(isValidDestinationHost, { + message: t("redirectDestinationHostInvalid") }), pathMatchType: z.enum(["exact", "prefix", "regex"]), matchPath: z.string().trim().nullable(), @@ -196,7 +198,7 @@ export default function RedirectForm({ domainId: redirect?.domainId ?? null, subdomain: redirect?.subdomain ?? null, resourceId: redirect?.resourceId ?? null, - destinationDomain: redirect?.destinationDomain ?? "", + destinationHost: redirect?.destinationHost ?? "", pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE, matchPath: redirect?.matchPath ?? null, rewritePath: redirect?.rewritePath ?? null, @@ -209,29 +211,32 @@ export default function RedirectForm({ }); const attachTo = form.watch("attachTo"); + const ssl = form.watch("ssl"); const sourceFullDomain = attachTo === "domain" ? domainFullDomain : (selectedResource?.fullDomain ?? null); + // Resource-attached redirects inherit the resource's ssl setting + const sourceSsl = + attachTo === "domain" ? ssl : (selectedResource?.ssl ?? true); + const sourceHost = sourceFullDomain + ? `${sourceSsl ? "https" : "http"}://${sourceFullDomain}` + : null; // Mirror is UI-only state; on edit, infer it from whether the saved // destination already equals the source host. const [sameDomainAsSource, setSameDomainAsSource] = useState( - Boolean( - redirect && - sourceFullDomain && - redirect.destinationDomain === sourceFullDomain - ) + Boolean(redirect && sourceHost && redirect.destinationHost === sourceHost) ); useEffect(() => { - if (sameDomainAsSource && sourceFullDomain) { - form.setValue("destinationDomain", sourceFullDomain, { + if (sameDomainAsSource && sourceHost) { + form.setValue("destinationHost", sourceHost, { shouldValidate: true }); } - }, [sameDomainAsSource, sourceFullDomain, form]); + }, [sameDomainAsSource, sourceHost, form]); const pathMatchType = form.watch("pathMatchType"); const rewritePath = form.watch("rewritePath"); const rewritePathType = form.watch("rewritePathType"); @@ -253,7 +258,7 @@ export default function RedirectForm({ values.attachTo === "domain" ? values.subdomain || null : null, resourceId: values.attachTo === "resource" ? values.resourceId : null, - destinationDomain: values.destinationDomain.trim(), + destinationHost: values.destinationHost.trim(), pathMatchType: values.pathMatchType, matchPath: values.matchPath?.trim() || null, rewritePath: values.rewritePath?.trim() || null, @@ -674,18 +679,18 @@ export default function RedirectForm({ ( {t( - "redirectDestinationDomain" + "redirectDestinationHost" )} {t( - "redirectDestinationDomainDescription" + "redirectDestinationHostDescription" )} diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 2249c9b43..f4ccee7aa 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -50,7 +50,7 @@ export type RedirectRow = { niceId: string; name: string; subdomain: string | null; - destinationDomain: string; + destinationHost: string; pathMatchType: "exact" | "prefix" | "regex"; matchPath: string | null; rewritePath: string | null; @@ -301,7 +301,7 @@ export default function RedirectsTable({ }, { id: "destination", - accessorKey: "destinationDomain", + accessorKey: "destinationHost", friendlyName: t("redirectDestination"), header: () => ( {t("redirectDestination")} @@ -310,7 +310,7 @@ export default function RedirectsTable({ const redirect = row.original; return ( - {redirect.destinationDomain} + {redirect.destinationHost} {redirect.rewritePath && ( {redirect.rewritePathType === "prefix" From 3e29e110f2edb9019dbf8228d5672f2ad4691caa Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 22 Sep 2026 19:07:03 +0200 Subject: [PATCH 030/107] =?UTF-8?q?=E2=9A=B0=EF=B8=8F=20remove=20useless?= =?UTF-8?q?=20useffect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RedirectsTable.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index f4ccee7aa..76de82872 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -35,7 +35,6 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - useEffect, useMemo, useOptimistic, useRef, @@ -44,6 +43,7 @@ import { type ComponentRef } from "react"; import { useDebouncedCallback } from "use-debounce"; +import { wait } from "@app/lib/wait"; export type RedirectRow = { redirectId: number; @@ -91,16 +91,11 @@ export default function RedirectsTable({ searchParams } = useNavigationContext(); - const [rows, setRows] = useState(redirects); const [selected, setSelected] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - useEffect(() => { - setRows(redirects); - }, [redirects]); - function refreshData() { startTransition(() => { try { @@ -156,9 +151,6 @@ export default function RedirectsTable({ startTransition(async () => { try { await api.delete(`/org/${orgId}/redirects/${row.redirectId}`); - setRows((prev) => - prev.filter((r) => r.redirectId !== row.redirectId) - ); setIsDeleteModalOpen(false); setSelected(null); toast({ @@ -443,7 +435,7 @@ export default function RedirectsTable({ startNavigation(() => From 404594ffd7214e01d6dd33b796ec47304e774377 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 22 Sep 2026 19:15:36 +0200 Subject: [PATCH 031/107] =?UTF-8?q?=F0=9F=9A=B8=20redirect=20to=20list=20a?= =?UTF-8?q?fter=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RedirectForm.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index dedb245e8..c9932410c 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -227,7 +227,9 @@ export default function RedirectForm({ // 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) + Boolean( + redirect && sourceHost && redirect.destinationHost === sourceHost + ) ); useEffect(() => { @@ -280,7 +282,6 @@ export default function RedirectForm({ title: t("success"), description: t("redirectUpdated") }); - router.refresh(); } else { const res = await api.put< AxiosResponse @@ -289,8 +290,8 @@ export default function RedirectForm({ title: t("success"), description: t("redirectCreated") }); - router.push(`/${orgId}/settings/redirects/`); } + router.push(`/${orgId}/settings/redirects/`); } catch (e) { toast({ variant: "destructive", From 14fda9cec09ff2d2deb317087feb87e84b1beb96 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 23 Sep 2026 20:49:45 +0200 Subject: [PATCH 032/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Force=20disable=20?= =?UTF-8?q?redirects=20when=20domain=20is=20missing=20attached=20resource?= =?UTF-8?q?=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/db/queries/verifySessionQueries.ts | 10 +- server/lib/traefik/getTraefikConfig.ts | 9 +- .../private/lib/traefik/getTraefikConfig.ts | 116 +++++++++--------- 4 files changed, 74 insertions(+), 62 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 36d768d92..d3b3079ea 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2580,6 +2580,7 @@ "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.", + "rdirectDomainNotFoundDescription": "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.", "failed": "Failed", "createNewOrgDescription": "Create a new organization", "organization": "Organization", diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 101e43722..cf04d4b3f 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -40,7 +40,7 @@ import { domains } from "@server/db"; import { alias } from "@server/db"; -import { and, desc, 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 = { @@ -116,7 +116,13 @@ export async function getRedirectsByHost( eq(redirects.enabled, true), // Traefik drops resource-attached redirects along with a // disabled resource; do the same here. - or(isNull(redirects.resourceId), eq(resources.enabled, true)), + or( + isNull(redirects.resourceId), + and( + eq(resources.enabled, true), + not(isNull(resources.domainId)) + ) + ), inArray(redirectHost, candidates) ) ) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 93127346d..0e8dcae85 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -20,6 +20,7 @@ import { inArray, isNotNull, isNull, + not, or, sql } from "drizzle-orm"; @@ -367,7 +368,13 @@ export async function getTraefikConfig( .where( and( eq(redirects.enabled, true), - or(isNull(redirects.resourceId), eq(resources.enabled, true)) + or( + isNull(redirects.resourceId), + and( + eq(resources.enabled, true), + not(isNull(resources.domainId)) + ) + ) ) ) .orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 18996b761..fc9160666 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,77 +23,70 @@ import { exitNodes, loginPage, redirects, - 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, 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 { ResourceWithTargets } 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, buildStickySessionCookie, - buildTcpUdpLoadBalancerServers, - buildStickySessionIp + buildStickySessionIp, + buildTcpUdpLoadBalancerServers } from "@server/lib/traefik/loadBalancer"; -import { buildCustomHeadersMiddleware } from "@server/lib/traefik/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 "@server/lib/traefik/aiGatewayMiddlewares"; -import { - buildBrowserGatewayResourcesMap, - buildBrowserGatewayConfig -} from "@server/lib/traefik/browserGateway"; -import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias"; +import { applyPathRewriteMiddleware } from "@server/lib/traefik/middleware"; import { buildRedirectConfig, RedirectRouteRow } from "@server/lib/traefik/redirect"; +import { + 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"; @@ -445,7 +442,13 @@ export async function getTraefikConfig( .where( and( eq(redirects.enabled, true), - or(isNull(redirects.resourceId), eq(resources.enabled, true)) + or( + isNull(redirects.resourceId), + and( + eq(resources.enabled, true), + not(isNull(resources.domainId)) + ) + ) ) ) .orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering @@ -563,11 +566,6 @@ export async function getTraefikConfig( } }; - console.dir( - { resourcesMap, resourcesWithTargetsAndSites }, - { depth: null } - ); - // get the key and the resource for (const [, resource] of resourcesMap.entries()) { const targets = resource.targets; From 0b0f62d8de6f758b2a98d638e3f24359c37e7c7f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 23 Sep 2026 21:00:11 +0200 Subject: [PATCH 033/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20fix=20types=20and?= =?UTF-8?q?=20validate=20destination=20Host=20correcly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/traefik/types.ts | 8 ++++++-- server/routers/badger/verifySession.ts | 2 +- server/routers/redirect/validation.ts | 15 +++++++++------ src/components/RedirectsTable.tsx | 24 +++++++++++++++++++++--- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/server/lib/traefik/types.ts b/server/lib/traefik/types.ts index 122746419..e6a552d9c 100644 --- a/server/lib/traefik/types.ts +++ b/server/lib/traefik/types.ts @@ -36,7 +36,8 @@ export type ResourceWithTargets = Pick< | "tlsServerName" | "setHostHeader" | "enableProxy" - | "headers" + | "requestHeaders" + | "responseHeaders" | "proxyProtocol" | "wildcard" | "mode" @@ -46,7 +47,10 @@ export type ResourceWithTargets = Pick< | "maintenanceMessage" | "maintenanceEstimatedTime" > & - Pick & { + Pick< + Target, + "path" | "pathMatchType" | "rewritePath" | "rewritePathType" + > & { /** Sanitized resource name used in router/service names */ name: string; /** Sanitized resourceId + path config, unique per router */ diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index f6e279d77..1e6fc94f6 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -41,7 +41,7 @@ import { } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; -import { isPathAllowed, type isPathAllowed } from "@server/lib/pathMatch"; +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"; diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index 324ddf6ff..fa304fd9e 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -39,9 +39,7 @@ export function isValidMatchPath( matchPath: string | null | undefined, pathMatchType: string | null | undefined ): boolean { - return ( - pathMatchType !== "regex" || !matchPath || isValidRegex(matchPath) - ); + return pathMatchType !== "regex" || !matchPath || isValidRegex(matchPath); } export const redirectRewritePathSchema = z.string().nonempty(); @@ -54,15 +52,20 @@ export const redirectPrioritySchema = z.int().min(1).max(1000); * the request path (after any rewrite) is appended to it by badger. */ export function isValidDestinationHost(value: string): boolean { - const match = /^https?:\/\/([^/:?#]+)(:\d{1,5})?$/.exec(value); - return match !== null && isValidDomain(match[1]); + 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" + message: + "Invalid destination, expected scheme://host such as https://example.com" }); /** diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 76de82872..4579e120e 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -43,7 +43,7 @@ import { type ComponentRef } from "react"; import { useDebouncedCallback } from "use-debounce"; -import { wait } from "@app/lib/wait"; +import { InfoPopup } from "./ui/info-popup"; export type RedirectRow = { redirectId: number; @@ -265,6 +265,17 @@ export default function RedirectsTable({ const certDomainId = redirect.resourceDomainId ?? redirect.domainId; + if (redirect.resourceId && !redirect.resourceDomainId) { + return ( +
+ +
+ ); + } + return (
{certDomainId && host ? ( @@ -479,9 +490,14 @@ function RedirectEnabledForm({ 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); @@ -490,8 +506,10 @@ function RedirectEnabledForm({ return (
formRef.current?.requestSubmit()} /> From 5430e5687c2191eba1d9b2fe9f7fde4d757b1133 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 23 Sep 2026 21:22:43 +0200 Subject: [PATCH 034/107] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Show=20popup=20for?= =?UTF-8?q?=20invalid=20redirect=20resource=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 +- server/routers/redirect/getRedirect.ts | 4 +- .../settings/redirects/[niceId]/page.tsx | 1 + src/components/RedirectForm.tsx | 59 ++++++++++++++----- src/components/RedirectsTable.tsx | 4 +- src/components/resource-selector.tsx | 8 ++- 6 files changed, 60 insertions(+), 19 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d3b3079ea..39327ae7b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2580,7 +2580,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.", - "rdirectDomainNotFoundDescription": "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.", + "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", diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index f2eae2961..a2a6c4a4f 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -29,6 +29,7 @@ export type GetRedirectResponse = { resourceId: number | null; resourceName: string | null; resourceNiceId: string | null; + resourceDomainId: string | null; resourceFullDomain: string | null; resourceSsl: boolean | null; resourceWildcard: boolean | null; @@ -59,7 +60,8 @@ const redirectColumns = { resourceSsl: resources.ssl, resourceWildcard: resources.wildcard, domainId: redirects.domainId, - baseDomain: domains.baseDomain + baseDomain: domains.baseDomain, + resourceDomainId: resources.domainId }; const paramsSchema = z.strictObject({ diff --git a/src/app/[orgId]/settings/redirects/[niceId]/page.tsx b/src/app/[orgId]/settings/redirects/[niceId]/page.tsx index 8ceb1fc9e..7a0f11f21 100644 --- a/src/app/[orgId]/settings/redirects/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/redirects/[niceId]/page.tsx @@ -40,6 +40,7 @@ export default async function EditRedirectPage(props: EditRedirectPageProps) { const initialResource = redirect.resourceId && redirect.resourceNiceId ? { + domainId: redirect.resourceDomainId, resourceId: redirect.resourceId, niceId: redirect.resourceNiceId, name: redirect.resourceName ?? redirect.resourceNiceId, diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index c9932410c..f57e6b9c3 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -1,6 +1,13 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import DomainPicker from "@app/components/DomainPicker"; +import { + PathMatchDisplay, + PathMatchModal, + PathRewriteDisplay, + PathRewriteModal +} from "@app/components/PathMatchRenameModal"; import { SettingsContainer, SettingsFormCell, @@ -40,34 +47,34 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { - isValidDestinationHost, - isValidRegex -} from "@server/routers/redirect/validation"; -import { build } from "@server/build"; import { cn } from "@app/lib/cn"; -import { CaretSortIcon } from "@radix-ui/react-icons"; 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 { 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 { - PathMatchDisplay, - PathMatchModal, - PathRewriteDisplay, - PathRewriteModal -} from "@app/components/PathMatchRenameModal"; -import { Plus } from "lucide-react"; -import DomainPicker from "@app/components/DomainPicker"; -import Link from "next/link"; + 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; @@ -213,6 +220,11 @@ export default function RedirectForm({ const attachTo = form.watch("attachTo"); const ssl = form.watch("ssl"); + const resourceMissingDomain = + attachTo === "resource" && + Boolean(selectedResource) && + !selectedResource?.domainId; + const sourceFullDomain = attachTo === "domain" ? domainFullDomain @@ -364,7 +376,20 @@ export default function RedirectForm({ - + + {resourceMissingDomain && ( + + + + {t( + "redirectDomainSelectedDomainNotFound" + )} + + + )}
diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index 5dad2bf0e..5a4704e44 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -17,7 +17,13 @@ import { useDebounce } from "use-debounce"; export type SelectedResource = Pick< ListResourcesResponse["resources"][number], - "name" | "resourceId" | "fullDomain" | "niceId" | "ssl" | "wildcard" + | "name" + | "resourceId" + | "fullDomain" + | "niceId" + | "ssl" + | "wildcard" + | "domainId" >; export type ResourceSelectorProps = { From 2c53975be53d26d35a5d14d0f1846b8c02e3c06c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 23 Sep 2026 21:33:04 +0200 Subject: [PATCH 035/107] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/resource-selector.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index 5a4704e44..1537c403b 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -17,14 +17,8 @@ import { useDebounce } from "use-debounce"; export type SelectedResource = Pick< ListResourcesResponse["resources"][number], - | "name" - | "resourceId" - | "fullDomain" - | "niceId" - | "ssl" - | "wildcard" - | "domainId" ->; + "name" | "resourceId" | "fullDomain" | "niceId" | "ssl" | "wildcard" +> & { domainId?: string | null }; export type ResourceSelectorProps = { orgId: string; From 6ddb1550e91d6b226aab1eaa5658fa3229cdbfca Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 16:31:54 -0400 Subject: [PATCH 036/107] Export blueprint functionality --- messages/en-US.json | 5 + .../lib/blueprints/exportResourceBlueprint.ts | 682 ++++++++++++++++++ server/routers/external.ts | 14 + .../resource/exportResourceBlueprint.ts | 100 +++ server/routers/resource/index.ts | 1 + .../exportSiteResourceBlueprint.ts | 100 +++ server/routers/siteResource/index.ts | 1 + src/components/ExportBlueprintModal.tsx | 138 ++++ src/components/PrivateResourcesTable.tsx | 29 + src/components/PublicResourcesTable.tsx | 28 + 10 files changed, 1098 insertions(+) create mode 100644 server/lib/blueprints/exportResourceBlueprint.ts create mode 100644 server/routers/resource/exportResourceBlueprint.ts create mode 100644 server/routers/siteResource/exportSiteResourceBlueprint.ts create mode 100644 src/components/ExportBlueprintModal.tsx diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..64c61f5b6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2370,6 +2370,11 @@ "blueprintContentsDescription": "Define the YAML content describing the infrastructure", "blueprintErrorCreateDescription": "An error occurred when applying the blueprint", "blueprintErrorCreate": "Error creating blueprint", + "exportAsBlueprint": "Export as Blueprint", + "exportBlueprintTitle": "Export Blueprint", + "exportBlueprintDescription": "Download a blueprint YAML snippet for \"{name}\" that can be applied to recreate this resource.", + "exportBlueprintError": "Failed to generate blueprint", + "download": "Download", "searchBlueprintProgress": "Search blueprints...", "appliedAt": "Applied At", "source": "Source", diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts new file mode 100644 index 000000000..5b825e74c --- /dev/null +++ b/server/lib/blueprints/exportResourceBlueprint.ts @@ -0,0 +1,682 @@ +import { stringify as stringifyYaml } from "yaml"; +import { and, asc, eq, isNull, not, or } from "drizzle-orm"; +import { + aiBudgets, + aiModels, + clients, + clientSiteResources, + db, + labels, + resourceAiModels, + resourceHeaderAuth, + resourceLabels, + resourcePassword, + resourcePincode, + resourcePolicies, + resourceRules, + resources, + resourceWhitelist, + roles, + roleResources, + roleSiteResources, + siteNetworks, + siteResourceAiModels, + siteResourceLabels, + siteResources, + sites, + targetHealthCheck, + targets, + userResources, + userSiteResources, + users +} from "@server/db"; +import { queryResourcePolicy } from "@server/routers/policy/getResourcePolicy"; +import { + listPublicResourceAiProviders, + listSiteResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const HTTP_LIKE_PUBLIC_MODES = ["http", "ssh", "rdp", "vnc", "inference"]; + +function reverseRuleAction(action: string): "allow" | "deny" | "pass" { + if (action === "ACCEPT") return "allow"; + if (action === "PASS") return "pass"; + return "deny"; +} + +function reverseRuleMatch(match: string): string { + return match.toLowerCase(); +} + +function stripUndefined>(obj: T): T { + for (const key of Object.keys(obj)) { + if (obj[key] === undefined) { + delete obj[key]; + } + } + return obj; +} + +function buildHealthcheck(row: { + hcHostname: string | null; + hcPort: number | null; + hcEnabled: boolean | null; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}) { + if (!row.hcHostname) { + return undefined; + } + + let headers: { name: string; value: string }[] | undefined; + if (row.hcHeaders) { + try { + headers = JSON.parse(row.hcHeaders); + } catch { + headers = undefined; + } + } + + return stripUndefined({ + hostname: row.hcHostname, + port: row.hcPort ?? undefined, + enabled: row.hcEnabled ?? undefined, + path: row.hcPath ?? undefined, + scheme: row.hcScheme ?? undefined, + mode: row.hcMode ?? undefined, + interval: row.hcInterval ?? undefined, + "unhealthy-interval": row.hcUnhealthyInterval ?? undefined, + timeout: row.hcTimeout ?? undefined, + headers, + "follow-redirects": row.hcFollowRedirects ?? undefined, + method: row.hcMethod ?? undefined, + status: row.hcStatus ?? undefined, + "healthy-threshold": row.hcHealthyThreshold ?? undefined, + "unhealthy-threshold": row.hcUnhealthyThreshold ?? undefined + }); +} + +async function buildPublicAuthAndRules(resource: { + resourceId: number; + resourcePolicyId: number | null; + defaultResourcePolicyId: number | null; + sso: boolean | null; + skipToIdpId: number | null; +}): Promise<{ + policy: string | undefined; + auth: Record | undefined; + rules: Record[]; + notes: string[]; +}> { + const notes: string[] = []; + + if (resource.resourcePolicyId) { + // Shared policy attached: the policy's own auth config lives on the + // policy entity itself (edited separately). The resource only carries + // its own overrides in the legacy resource-level tables. + const [policyRow] = await db + .select({ niceId: resourcePolicies.niceId }) + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resource.resourcePolicyId)) + .limit(1); + + const [roleRows, userRows, whitelistRows, ruleRows, passwordRow, pincodeRow, headerAuthRow] = + await Promise.all([ + db + .select({ name: roles.name }) + .from(roleResources) + .innerJoin( + roles, + and( + eq(roleResources.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(roleResources.resourceId, resource.resourceId)), + db + .select({ username: users.username }) + .from(userResources) + .innerJoin(users, eq(userResources.userId, users.userId)) + .where(eq(userResources.resourceId, resource.resourceId)), + db + .select({ email: resourceWhitelist.email }) + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resource.resourceId)), + db + .select({ + enabled: resourceRules.enabled, + priority: resourceRules.priority, + action: resourceRules.action, + match: resourceRules.match, + value: resourceRules.value + }) + .from(resourceRules) + .where(eq(resourceRules.resourceId, resource.resourceId)), + db + .select({ passwordId: resourcePassword.passwordId }) + .from(resourcePassword) + .where(eq(resourcePassword.resourceId, resource.resourceId)) + .limit(1), + db + .select({ pincodeId: resourcePincode.pincodeId }) + .from(resourcePincode) + .where(eq(resourcePincode.resourceId, resource.resourceId)) + .limit(1), + db + .select({ headerAuthId: resourceHeaderAuth.headerAuthId }) + .from(resourceHeaderAuth) + .where(eq(resourceHeaderAuth.resourceId, resource.resourceId)) + .limit(1) + ]); + + if (passwordRow.length > 0) { + notes.push( + "A password is configured on this resource. Passwords cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (pincodeRow.length > 0) { + notes.push( + "A pincode is configured on this resource. Pincodes cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (headerAuthRow.length > 0) { + notes.push( + "Basic auth is configured on this resource. Basic auth credentials cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + + const auth = stripUndefined({ + "sso-enabled": resource.sso ?? undefined, + "sso-roles": roleRows.length ? roleRows.map((r) => r.name) : undefined, + "sso-users": userRows.length + ? userRows.map((u) => u.username) + : undefined, + "whitelist-users": whitelistRows.length + ? whitelistRows.map((w) => w.email) + : undefined, + "auto-login-idp": resource.skipToIdpId ?? undefined + }); + + return { + policy: policyRow?.niceId, + auth: Object.keys(auth).length > 0 ? auth : undefined, + rules: ruleRows.map((r) => + stripUndefined({ + action: reverseRuleAction(r.action), + match: reverseRuleMatch(r.match), + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + notes + }; + } + + if (!resource.defaultResourcePolicyId) { + return { policy: undefined, auth: undefined, rules: [], notes }; + } + + // Inline policy: the resource's own default (non-shared) policy carries + // all of its auth config. + const policy = await queryResourcePolicy({ + resourcePolicyId: resource.defaultResourcePolicyId + }); + + if (!policy) { + return { policy: undefined, auth: undefined, rules: [], notes }; + } + + if (policy.passwordId) { + notes.push( + "A password is configured on this resource. Passwords cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (policy.pincodeId) { + notes.push( + "A pincode is configured on this resource. Pincodes cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (policy.headerAuth?.id) { + notes.push( + "Basic auth is configured on this resource. Basic auth credentials cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + + const auth = stripUndefined({ + "sso-enabled": policy.sso ?? undefined, + "sso-roles": policy.roles.length + ? policy.roles.map((r) => r.name) + : undefined, + "sso-users": policy.users.length + ? policy.users.map((u) => u.username) + : undefined, + "whitelist-users": policy.emailWhiteList.length + ? policy.emailWhiteList.map((w) => w.email) + : undefined, + "auto-login-idp": policy.idpId ?? undefined + }); + + return { + policy: undefined, + auth: Object.keys(auth).length > 0 ? auth : undefined, + rules: policy.rules.map((r) => + stripUndefined({ + action: reverseRuleAction(r.action), + match: reverseRuleMatch(r.match), + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + notes + }; +} + +async function buildAiProvidersAndBudget(input: + | { scope: "public"; resourceId: number } + | { scope: "site"; siteResourceId: number } +) { + const providers = + input.scope === "public" + ? await listPublicResourceAiProviders(input.resourceId) + : await listSiteResourceAiProviders(input.siteResourceId); + + const aiProvidersConfig = await Promise.all( + providers.map(async (p) => { + const entry: Record = { + provider: p.niceId, + "access-mode": p.accessMode, + enabled: p.enabled + }; + + if (p.accessMode === "select") { + const modelRows = + input.scope === "public" + ? await db + .select({ modelKey: aiModels.modelKey }) + .from(resourceAiModels) + .innerJoin( + aiModels, + eq(resourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq( + resourceAiModels.resourceId, + input.resourceId + ), + eq(aiModels.providerId, p.providerId) + ) + ) + : await db + .select({ modelKey: aiModels.modelKey }) + .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq( + siteResourceAiModels.siteResourceId, + input.siteResourceId + ), + eq(aiModels.providerId, p.providerId) + ) + ); + + if (modelRows.length > 0) { + entry.models = modelRows.map((m) => m.modelKey); + } + } + + return entry; + }) + ); + + const budgetRows = await db + .select() + .from(aiBudgets) + .where( + input.scope === "public" + ? eq(aiBudgets.resourceId, input.resourceId) + : eq(aiBudgets.siteResourceId, input.siteResourceId) + ); + + const aiBudgetConfig = budgetRows.map((b) => ({ + amount: b.amount, + unit: b.unit, + period: b.period, + enforcement: b.enforcement, + enabled: b.enabled + })); + + return { aiProvidersConfig, aiBudgetConfig }; +} + +export async function generatePublicResourceBlueprintYaml( + resourceId: number +): Promise<{ niceId: string; contents: string }> { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + throw new Error(`Resource ${resourceId} not found`); + } + + const mode = resource.mode; + const isHttpLike = HTTP_LIKE_PUBLIC_MODES.includes(mode); + const isInference = mode === "inference"; + + const resourceConfig: Record = { + name: resource.name, + mode, + enabled: resource.enabled + }; + + if (isHttpLike) { + if (resource.fullDomain) { + resourceConfig["full-domain"] = resource.fullDomain; + } + resourceConfig.ssl = resource.ssl; + } else { + if (resource.proxyPort) { + resourceConfig["proxy-port"] = resource.proxyPort; + } + if (mode === "tcp") { + resourceConfig["proxy-protocol"] = resource.proxyProtocol; + resourceConfig["proxy-protocol-version"] = + resource.proxyProtocolVersion ?? undefined; + } + } + + if (resource.setHostHeader) { + resourceConfig["host-header"] = resource.setHostHeader; + } + if (resource.tlsServerName) { + resourceConfig["tls-server-name"] = resource.tlsServerName; + } + if (resource.requestHeaders) { + try { + resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders); + } catch { + // ignore malformed stored headers + } + } + if (resource.responseHeaders) { + try { + resourceConfig.responseHeaders = JSON.parse(resource.responseHeaders); + } catch { + // ignore malformed stored headers + } + } + + if (mode === "ssh") { + resourceConfig["auth-daemon"] = stripUndefined({ + pam: resource.pamMode ?? undefined, + mode: resource.authDaemonMode ?? undefined, + port: resource.authDaemonPort ?? undefined + }); + } + + if (resource.maintenanceModeEnabled) { + resourceConfig.maintenance = stripUndefined({ + enabled: resource.maintenanceModeEnabled, + type: resource.maintenanceModeType ?? undefined, + title: resource.maintenanceTitle ?? undefined, + message: resource.maintenanceMessage ?? undefined, + "estimated-time": resource.maintenanceEstimatedTime ?? undefined + }); + } + + // Labels + const labelRows = await db + .select({ name: labels.name }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .where(eq(resourceLabels.resourceId, resourceId)); + if (labelRows.length > 0) { + resourceConfig.labels = labelRows.map((l) => l.name); + } + + // Auth / rules / shared policy + const { policy, auth, rules, notes } = await buildPublicAuthAndRules( + resource + ); + if (policy) { + resourceConfig.policy = policy; + } + if (auth) { + resourceConfig.auth = auth; + } + if (rules.length > 0) { + resourceConfig.rules = rules; + } + + // Targets (all modes except inference use targets) + if (!isInference) { + const targetRows = await db + .select({ + ip: targets.ip, + method: targets.method, + port: targets.port, + enabled: targets.enabled, + internalPort: targets.internalPort, + path: targets.path, + pathMatchType: targets.pathMatchType, + rewritePath: targets.rewritePath, + rewritePathType: targets.rewritePathType, + priority: targets.priority, + siteNiceId: sites.niceId, + hcEnabled: targetHealthCheck.hcEnabled, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, + hcMethod: targetHealthCheck.hcMethod, + hcStatus: targetHealthCheck.hcStatus, + hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold + }) + .from(targets) + .leftJoin(sites, eq(sites.siteId, targets.siteId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .where(eq(targets.resourceId, resourceId)) + .orderBy(asc(targets.targetId)); + + resourceConfig.targets = targetRows.map((row) => + stripUndefined({ + site: row.siteNiceId ?? undefined, + method: mode === "http" ? row.method ?? undefined : undefined, + hostname: row.ip, + port: row.port, + enabled: row.enabled, + "internal-port": row.internalPort ?? undefined, + path: row.path ?? undefined, + "path-match": row.pathMatchType ?? undefined, + "rewrite-path": row.rewritePath ?? undefined, + "rewrite-match": row.rewritePathType ?? undefined, + priority: row.priority, + healthcheck: buildHealthcheck(row) + }) + ); + } else { + const { aiProvidersConfig, aiBudgetConfig } = + await buildAiProvidersAndBudget({ scope: "public", resourceId }); + resourceConfig["ai-providers"] = aiProvidersConfig; + if (aiBudgetConfig.length > 0) { + resourceConfig["ai-budget"] = aiBudgetConfig; + } + } + + const config = { + "proxy-resources": { + [resource.niceId]: resourceConfig + } + }; + + const notesPrefix = + notes.length > 0 + ? notes.map((n) => `# ${n}`).join("\n") + "\n\n" + : ""; + + return { + niceId: resource.niceId, + contents: notesPrefix + stringifyYaml(config) + }; +} + +export async function generatePrivateResourceBlueprintYaml( + siteResourceId: number +): Promise<{ niceId: string; contents: string }> { + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + throw new Error(`Site resource ${siteResourceId} not found`); + } + + const mode = siteResource.mode; + const isInference = mode === "inference"; + const isHttpLike = mode === "http" || isInference; + + const resourceConfig: Record = { + name: siteResource.name, + mode, + enabled: siteResource.enabled + }; + + if (!isInference && siteResource.networkId) { + const siteRows = await db + .select({ niceId: sites.niceId }) + .from(siteNetworks) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(eq(siteNetworks.networkId, siteResource.networkId)); + if (siteRows.length > 0) { + resourceConfig.sites = siteRows.map((s) => s.niceId); + } + } + + if (siteResource.destination) { + resourceConfig.destination = siteResource.destination; + } + if (siteResource.destinationPort) { + resourceConfig["destination-port"] = siteResource.destinationPort; + } + + if (!isHttpLike) { + resourceConfig["tcp-ports"] = siteResource.tcpPortRangeString; + resourceConfig["udp-ports"] = siteResource.udpPortRangeString; + resourceConfig["disable-icmp"] = siteResource.disableIcmp; + } + + if (isHttpLike) { + if (siteResource.fullDomain) { + resourceConfig["full-domain"] = siteResource.fullDomain; + } + resourceConfig.ssl = siteResource.ssl; + if (siteResource.scheme) { + resourceConfig.scheme = siteResource.scheme; + } + } + + if (siteResource.alias) { + resourceConfig.alias = siteResource.alias; + } + + if (mode === "ssh") { + resourceConfig["auth-daemon"] = stripUndefined({ + pam: siteResource.pamMode ?? undefined, + mode: siteResource.authDaemonMode ?? undefined, + port: siteResource.authDaemonPort ?? undefined + }); + } + + const [roleRows, userRows, clientRows, labelRows] = await Promise.all([ + db + .select({ name: roles.name }) + .from(roleSiteResources) + .innerJoin( + roles, + and( + eq(roleSiteResources.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(roleSiteResources.siteResourceId, siteResourceId)), + db + .select({ username: users.username }) + .from(userSiteResources) + .innerJoin(users, eq(userSiteResources.userId, users.userId)) + .where(eq(userSiteResources.siteResourceId, siteResourceId)), + db + .select({ niceId: clients.niceId }) + .from(clientSiteResources) + .innerJoin(clients, eq(clientSiteResources.clientId, clients.clientId)) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)), + db + .select({ name: labels.name }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .where(eq(siteResourceLabels.siteResourceId, siteResourceId)) + ]); + + if (roleRows.length > 0) { + resourceConfig.roles = roleRows.map((r) => r.name); + } + if (userRows.length > 0) { + resourceConfig.users = userRows.map((u) => u.username); + } + if (clientRows.length > 0) { + resourceConfig.machines = clientRows.map((c) => c.niceId); + } + if (labelRows.length > 0) { + resourceConfig.labels = labelRows.map((l) => l.name); + } + + if (isInference) { + const { aiProvidersConfig, aiBudgetConfig } = + await buildAiProvidersAndBudget({ scope: "site", siteResourceId }); + resourceConfig["ai-providers"] = aiProvidersConfig; + if (aiBudgetConfig.length > 0) { + resourceConfig["ai-budget"] = aiBudgetConfig; + } + } + + const config = { + "client-resources": { + [siteResource.niceId]: resourceConfig + } + }; + + return { + niceId: siteResource.niceId, + contents: stringifyYaml(config) + }; +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 33d1fea6c..382e3e826 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -386,6 +386,13 @@ authenticated.get( siteResource.getSiteResource ); +authenticated.get( + "/site-resource/:siteResourceId/blueprint", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.getSiteResource), + siteResource.exportSiteResourceBlueprint +); + authenticated.post( "/site-resource/:siteResourceId", verifySiteResourceAccess, @@ -764,6 +771,13 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); + +authenticated.get( + "/resource/:resourceId/blueprint", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.exportResourceBlueprint +); authenticated.get( "/org/:orgId/resource/:niceId", verifyOrgAccess, diff --git a/server/routers/resource/exportResourceBlueprint.ts b/server/routers/resource/exportResourceBlueprint.ts new file mode 100644 index 000000000..3e6d8e1a2 --- /dev/null +++ b/server/routers/resource/exportResourceBlueprint.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +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 { generatePublicResourceBlueprintYaml } from "@server/lib/blueprints/exportResourceBlueprint"; + +const exportResourceBlueprintSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +export type ExportResourceBlueprintResponse = { + name: string; + contents: string; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/blueprint", + description: + "Generate a blueprint YAML snippet that recreates this resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Blueprint], + request: { + params: exportResourceBlueprintSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function exportResourceBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = exportResourceBlueprintSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ niceId: resources.niceId }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const { niceId, contents } = + await generatePublicResourceBlueprintYaml(resourceId); + + return response(res, { + data: { name: niceId, contents }, + success: true, + error: false, + message: "Blueprint generated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : "An error occurred" + ) + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index a09cf0452..071f173db 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -43,3 +43,4 @@ export * from "./listResourceAiProviders"; export * from "./setResourceAiProviders"; export * from "./addAiProviderToResource"; export * from "./removeAiProviderFromResource"; +export * from "./exportResourceBlueprint"; diff --git a/server/routers/siteResource/exportSiteResourceBlueprint.ts b/server/routers/siteResource/exportSiteResourceBlueprint.ts new file mode 100644 index 000000000..e5d92446d --- /dev/null +++ b/server/routers/siteResource/exportSiteResourceBlueprint.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { eq } from "drizzle-orm"; +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 { generatePrivateResourceBlueprintYaml } from "@server/lib/blueprints/exportResourceBlueprint"; + +const exportSiteResourceBlueprintSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +export type ExportSiteResourceBlueprintResponse = { + name: string; + contents: string; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/blueprint", + description: + "Generate a blueprint YAML snippet that recreates this site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Blueprint], + request: { + params: exportSiteResourceBlueprintSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function exportSiteResourceBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = exportSiteResourceBlueprintSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select({ niceId: siteResources.niceId }) + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + const { niceId, contents } = + await generatePrivateResourceBlueprintYaml(siteResourceId); + + return response(res, { + data: { name: niceId, contents }, + success: true, + error: false, + message: "Blueprint generated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : "An error occurred" + ) + ); + } +} diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 046ed0a13..243074197 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -25,3 +25,4 @@ export * from "./listSiteResourceAiProviders"; export * from "./setSiteResourceAiProviders"; export * from "./addAiProviderToSiteResource"; export * from "./removeAiProviderFromSiteResource"; +export * from "./exportSiteResourceBlueprint"; diff --git a/src/components/ExportBlueprintModal.tsx b/src/components/ExportBlueprintModal.tsx new file mode 100644 index 000000000..d6ad61440 --- /dev/null +++ b/src/components/ExportBlueprintModal.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Editor from "@monaco-editor/react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +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 { Download, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type ExportBlueprintModalProps = { + open: boolean; + setOpen: (open: boolean) => void; + endpoint: string; + resourceName: string; +}; + +export default function ExportBlueprintModal({ + open, + setOpen, + endpoint, + resourceName +}: ExportBlueprintModalProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [loading, setLoading] = useState(false); + const [contents, setContents] = useState(null); + const [fileName, setFileName] = useState(resourceName); + + useEffect(() => { + if (!open) { + return; + } + + setContents(null); + setLoading(true); + + api.get(endpoint) + .then((res) => { + setContents(res.data.data.contents); + setFileName(res.data.data.name || resourceName); + }) + .catch((e) => { + toast({ + variant: "destructive", + title: t("exportBlueprintError"), + description: formatAxiosError( + e, + t("exportBlueprintError") + ) + }); + setOpen(false); + }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, endpoint]); + + function handleDownload() { + if (!contents) { + return; + } + + const blob = new Blob([contents], { type: "application/x-yaml" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `${fileName}.yaml`); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + } + + return ( + + + + {t("exportBlueprintTitle")} + + {t("exportBlueprintDescription", { + name: resourceName + })} + + + +
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 02b4500ac..4e3461ed5 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -2,6 +2,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import ExportBlueprintModal from "@app/components/ExportBlueprintModal"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { ResourceSitesStatusCell, @@ -171,6 +172,10 @@ export default function PrivateResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [exportResource, setExportResource] = + useState(null); + const [isRefreshing, startRefreshTransition] = useTransition(); const { isPaidUser } = usePaidStatus(); @@ -566,6 +571,14 @@ export default function PrivateResourcesTable({ {t("viewSettings")} + { + setExportResource(resourceRow); + setIsExportModalOpen(true); + }} + > + {t("exportAsBlueprint")} + { setSelectedInternalResource( @@ -668,6 +681,22 @@ export default function PrivateResourcesTable({ /> )} + {exportResource && ( + { + setIsExportModalOpen(val); + if (!val) { + setExportResource(null); + } + }} + endpoint={`/site-resource/${exportResource.id}/blueprint`} + resourceName={ + exportResource.niceId || exportResource.name + } + /> + )} + (); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [exportResource, setExportResource] = useState( + null + ); + const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -617,6 +623,14 @@ export default function PublicResourcesTable({ {t("viewSettings")} + { + setExportResource(resourceRow); + setIsExportModalOpen(true); + }} + > + {t("exportAsBlueprint")} + { setSelectedResource(resourceRow); @@ -720,6 +734,20 @@ export default function PublicResourcesTable({ /> )} + {exportResource && ( + { + setIsExportModalOpen(val); + if (!val) { + setExportResource(null); + } + }} + endpoint={`/resource/${exportResource.id}/blueprint`} + resourceName={exportResource.nice || exportResource.name} + /> + )} + Date: Thu, 24 Sep 2026 16:56:57 -0400 Subject: [PATCH 037/107] Add fist pass of mtls support --- messages/en-US.json | 33 ++ server/auth/actions.ts | 3 + server/db/pg/schema/schema.ts | 20 + server/db/sqlite/schema/schema.ts | 27 + server/lib/billing/tierMatrix.ts | 6 +- server/lib/blueprints/MtlsSchema.ts | 3 + server/lib/blueprints/publicResources.ts | 95 ++++ server/lib/blueprints/types.ts | 44 ++ server/lib/mtlsCertificate.ts | 61 +++ server/private/lib/blueprints/MtlsSchema.ts | 19 + .../private/lib/traefik/getTraefikConfig.ts | 87 +++- .../routers/billing/featureLifecycle.ts | 16 + server/routers/external.ts | 22 + server/routers/integration.ts | 33 ++ .../resource/createResourceMtlsCertificate.ts | 239 +++++++++ .../resource/deleteResourceMtlsCertificate.ts | 135 +++++ server/routers/resource/index.ts | 3 + .../resource/listResourceMtlsCertificates.ts | 111 ++++ server/routers/resource/updateResource.ts | 61 ++- .../resources/public/[niceId]/layout.tsx | 7 + .../resources/public/[niceId]/mtls/page.tsx | 490 ++++++++++++++++++ src/lib/queries.ts | 12 + 22 files changed, 1521 insertions(+), 6 deletions(-) create mode 100644 server/lib/blueprints/MtlsSchema.ts create mode 100644 server/lib/mtlsCertificate.ts create mode 100644 server/private/lib/blueprints/MtlsSchema.ts create mode 100644 server/routers/resource/createResourceMtlsCertificate.ts create mode 100644 server/routers/resource/deleteResourceMtlsCertificate.ts create mode 100644 server/routers/resource/listResourceMtlsCertificates.ts create mode 100644 src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..fd4ac085f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3892,6 +3892,39 @@ "learnMore": "Learn more", "backToHome": "Go back to home", "needToSignInToOrg": "Need to use your organization's identity provider?", + "mtls": "mTLS", + "mtlsCaCertificates": "Trusted CA certificates", + "mtlsCaCertificatesDescription": "Clients must present a certificate signed by one of these certificate authorities to connect. Upload public CA certificates only, never a private key.", + "mtlsCertificateName": "Name (optional)", + "mtlsCertificateNamePlaceholder": "Corporate device CA", + "mtlsCertificatePem": "CA certificate (PEM)", + "mtlsCertificatePemDescription": "Paste a PEM-encoded certificate or a bundle of certificates, or upload a .pem, .crt or .cer file.", + "mtlsUploadFile": "Upload file", + "mtlsAddCertificate": "Add CA certificate", + "mtlsCertificateAdded": "CA certificate added", + "mtlsCertificateAddedDescription": "Clients with certificates signed by this CA can now be verified.", + "mtlsCertificateErrorAdd": "Failed to add CA certificate", + "mtlsCertificateErrorAddDescription": "The certificate could not be added.", + "mtlsCertificateRemoved": "CA certificate removed", + "mtlsCertificateErrorRemove": "Failed to remove CA certificate", + "mtlsCertificateErrorRemoveDescription": "The certificate could not be removed.", + "mtlsLastCertificateRemoved": "mTLS was turned off because the last CA certificate was removed.", + "mtlsNoCertificates": "No CA certificates added yet", + "mtlsRemoveCertificate": "Remove CA certificate", + "mtlsExpired": "Expired", + "mtlsExpiresSoon": "Expires soon", + "mtlsExpiresOn": "Expires {date}", + "mtlsIssuedBy": "Issued by {issuer}", + "mtlsFingerprint": "SHA-256 fingerprint", + "mtlsEnforcement": "Client certificate enforcement", + "mtlsEnforcementDescription": "Control whether this resource requires a client certificate.", + "enableMtls": "Require client certificate", + "enableMtlsDescription": "Reject connections that don't present a valid certificate from a trusted CA. This is checked during the TLS handshake, before any other authentication method.", + "mtlsRequiresCertificate": "Add at least one CA certificate to enable mTLS.", + "mtlsRequiresSsl": "mTLS requires SSL. Enable SSL in this resource's HTTP settings first.", + "mtlsForwardedHeadersTitle": "Client certificate details are forwarded", + "mtlsForwardedHeadersDescription": "Your backend receives the verified certificate's subject, issuer, serial number and validity in the X-Forwarded-Tls-Client-Cert-Info header.", + "mtlsHandshakeNote": "Clients without a valid certificate fail the TLS handshake, so browsers show a connection error rather than a login page.", "maintenanceMode": "Maintenance Page", "maintenanceModeDescription": "Display a maintenance page to visitors", "maintenanceModeType": "Maintenance Mode Type", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 75caed82d..9af39bd9f 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -81,6 +81,9 @@ export enum ActionsEnum { deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", + createResourceMtlsCertificate = "createResourceMtlsCertificate", + deleteResourceMtlsCertificate = "deleteResourceMtlsCertificate", + listResourceMtlsCertificates = "listResourceMtlsCertificates", createSiteResource = "createSiteResource", deleteSiteResource = "deleteSiteResource", getSiteResource = "getSiteResource", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 145b35756..1c03bf1d6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -200,6 +200,7 @@ export const resources = pgTable( maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + mtlsEnabled: boolean("mtlsEnabled").notNull().default(false), postAuthPath: text("postAuthPath"), health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: boolean("wildcard").notNull().default(false), @@ -1107,6 +1108,22 @@ export const resourceWhitelist = pgTable("resourceWhitelist", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); +export const resourceMtlsCertificates = pgTable("resourceMtlsCertificates", { + mtlsCertificateId: serial("mtlsCertificateId").primaryKey(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: varchar("name"), + certificate: text("certificate").notNull(), + subject: varchar("subject"), + issuer: varchar("issuer"), + serialNumber: varchar("serialNumber"), + fingerprint: varchar("fingerprint"), + notBefore: bigint("notBefore", { mode: "number" }), + notAfter: bigint("notAfter", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull() +}); + export const resourceOtp = pgTable("resourceOtp", { otpId: serial("otpId").primaryKey(), resourceId: integer("resourceId") @@ -2019,6 +2036,9 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; export type ResourcePolicyPincode = InferSelectModel< typeof resourcePolicyPincode >; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index a72e15556..b479b4af9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -220,6 +220,9 @@ export const resources = sqliteTable( maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + mtlsEnabled: integer("mtlsEnabled", { mode: "boolean" }) + .notNull() + .default(false), postAuthPath: text("postAuthPath"), health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: integer("wildcard", { mode: "boolean" }) @@ -1377,6 +1380,27 @@ export const resourceWhitelist = sqliteTable("resourceWhitelist", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); +export const resourceMtlsCertificates = sqliteTable( + "resourceMtlsCertificates", + { + mtlsCertificateId: integer("mtlsCertificateId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: text("name"), + certificate: text("certificate").notNull(), + subject: text("subject"), + issuer: text("issuer"), + serialNumber: text("serialNumber"), + fingerprint: text("fingerprint"), + notBefore: integer("notBefore"), + notAfter: integer("notAfter"), + createdAt: integer("createdAt").notNull() + } +); + export const resourceOtp = sqliteTable("resourceOtp", { otpId: integer("otpId").primaryKey({ autoIncrement: true @@ -2062,6 +2086,9 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c04a60aed..b87b247ad 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -26,7 +26,8 @@ export enum TierFeature { WildcardSubdomain = "wildcardSubdomain", NewtAutoUpdate = "newtAutoUpdate", ResourcePolicies = "resourcePolicies", - RoleBasedSSHControls = "roleBasedSSHControls" + RoleBasedSSHControls = "roleBasedSSHControls", + Mtls = "mtls" // handle downgrade by disabling mTLS on resources } export const tierMatrix: Record = { @@ -70,5 +71,6 @@ export const tierMatrix: Record = { [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.ResourcePolicies]: ["tier3", "enterprise"], - [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"] + [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"], + [TierFeature.Mtls]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/lib/blueprints/MtlsSchema.ts b/server/lib/blueprints/MtlsSchema.ts new file mode 100644 index 000000000..5e7054239 --- /dev/null +++ b/server/lib/blueprints/MtlsSchema.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const MtlsSchema = z.object({}); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 719671d97..ffd4331df 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -10,6 +10,7 @@ import { Resource, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceMtlsCertificates, resourcePassword, resourcePincode, resourcePolicies, @@ -62,6 +63,10 @@ import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; import { syncInferenceAiConfig } from "./aiProviders"; import { syncAiBudgets } from "./aiBudgets"; +import { + MAX_MTLS_CERTIFICATES_PER_RESOURCE, + parseCaCertificates +} from "../mtlsCertificate"; export type PublicResourcesResults = { proxyResource: Resource; @@ -274,6 +279,20 @@ export async function updatePublicResources( ? JSON.stringify(resourceData.responseHeaders) : null; + // mTLS is a security control: refuse (rather than silently drop) a + // blueprint that asks for it on a plan that doesn't include it. + if (resourceData.mtls?.enabled) { + const mtlsLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.mtls + ); + if (!mtlsLicensed) { + throw new Error( + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ); + } + } + if (resourceData.policy) { const isLicensed = await isLicensedOrSubscribed( orgId, @@ -431,6 +450,7 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -618,6 +638,7 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -722,6 +743,14 @@ export async function updatePublicResources( resourceId: existingResource.resourceId, budgets: resourceData["ai-budget"] || [] }); + + if (resourceData.mtls?.["ca-certificates"]) { + await syncResourceMtlsCertificates( + existingResource.resourceId, + resourceData.mtls["ca-certificates"], + trx + ); + } } const existingResourceTargets = await trx @@ -1246,6 +1275,7 @@ export async function updatePublicResources( maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -1300,6 +1330,14 @@ export async function updatePublicResources( budgets: resourceData["ai-budget"] || [] }); + if (resourceData.mtls?.["ca-certificates"]) { + await syncResourceMtlsCertificates( + newResource.resourceId, + resourceData.mtls["ca-certificates"], + trx + ); + } + await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId @@ -1643,6 +1681,63 @@ async function syncUserResources( } } +async function syncResourceMtlsCertificates( + resourceId: number, + caCertificates: string[], + trx: Transaction +) { + // A PEM entry may itself be a bundle; flatten and dedupe by fingerprint. + const desired = new Map[0]>(); + for (const pem of caCertificates) { + for (const cert of parseCaCertificates(pem)) { + desired.set(cert.fingerprint, cert); + } + } + + if (desired.size > MAX_MTLS_CERTIFICATES_PER_RESOURCE) { + throw new Error( + `A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates` + ); + } + + const existing = await trx + .select() + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); + + for (const cert of desired.values()) { + if (existingFingerprints.has(cert.fingerprint)) { + continue; + } + await trx.insert(resourceMtlsCertificates).values({ + resourceId, + certificate: cert.certificate, + subject: cert.subject, + issuer: cert.issuer, + serialNumber: cert.serialNumber, + fingerprint: cert.fingerprint, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + createdAt: Date.now() + }); + } + + for (const row of existing) { + if (!row.fingerprint || !desired.has(row.fingerprint)) { + await trx + .delete(resourceMtlsCertificates) + .where( + eq( + resourceMtlsCertificates.mtlsCertificateId, + row.mtlsCertificateId + ) + ); + } + } +} + async function syncWhitelistUsers( resourceId: number, whitelistUsers: string[], diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index ff3996417..967150ba1 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { existsSync } from "node:fs"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; +import { MtlsSchema } from "#dynamic/lib/blueprints/MtlsSchema"; import { isValidRegionId } from "@server/db/regions"; import { isValidHttpMethodList } from "@server/lib/validators"; import { wildcardSubdomainSchema } from "@server/lib/schemas"; @@ -331,6 +332,7 @@ export const PublicResourceSchema = z responseHeaders: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), maintenance: MaintenanceSchema.optional(), + mtls: MtlsSchema.optional(), "auth-daemon": AuthDaemonSchema.optional(), "proxy-protocol": z.boolean().optional(), "proxy-protocol-version": z.int().min(1).optional(), @@ -445,6 +447,48 @@ export const PublicResourceSchema = z error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided" } ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + return (resource.mode ?? resource.protocol) === "http"; + }, + { + path: ["mtls"], + error: "mTLS is only supported when mode is 'http'" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + return resource.ssl !== false; + }, + { + path: ["mtls"], + error: "mTLS requires 'ssl' to be enabled" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + const caCertificates = ( + resource.mtls as { "ca-certificates"?: string[] } + )["ca-certificates"]; + return (caCertificates?.length ?? 0) > 0; + }, + { + path: ["mtls", "ca-certificates"], + error: "mTLS requires at least one entry in 'ca-certificates'" + } + ) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { diff --git a/server/lib/mtlsCertificate.ts b/server/lib/mtlsCertificate.ts new file mode 100644 index 000000000..169d97a87 --- /dev/null +++ b/server/lib/mtlsCertificate.ts @@ -0,0 +1,61 @@ +import { X509Certificate } from "crypto"; + +export const MAX_MTLS_CERTIFICATES_PER_RESOURCE = 20; + +export type ParsedCaCertificate = { + certificate: string; + subject: string; + issuer: string; + serialNumber: string; + fingerprint: string; + notBefore: number; + notAfter: number; +}; + +const PEM_CERTIFICATE_REGEX = + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +// Certificate subjects/issuers come back as newline-separated "CN=foo\nO=bar"; +// flatten to a single readable line for storage and display. +function flattenDn(dn: string): string { + return dn.split("\n").join(", "); +} + +/** + * Parses one or more PEM-encoded CA certificates (a bundle is split into its + * individual certificates). Throws an Error with a user-presentable message + * if the input is not a valid certificate or looks like a private key. + */ +export function parseCaCertificates(input: string): ParsedCaCertificate[] { + if (/PRIVATE KEY/.test(input)) { + throw new Error( + "This looks like a private key, not a certificate. Upload the CA's public certificate only." + ); + } + + const blocks = input.match(PEM_CERTIFICATE_REGEX); + if (!blocks || blocks.length === 0) { + throw new Error( + "Invalid certificate: no PEM-encoded certificate found. Expected a block starting with -----BEGIN CERTIFICATE-----." + ); + } + + return blocks.map((block) => { + let cert: X509Certificate; + try { + cert = new X509Certificate(block); + } catch { + throw new Error("Invalid certificate: could not parse PEM."); + } + + return { + certificate: block.trim() + "\n", + subject: flattenDn(cert.subject), + issuer: flattenDn(cert.issuer), + serialNumber: cert.serialNumber, + fingerprint: cert.fingerprint256, + notBefore: new Date(cert.validFrom).getTime(), + notAfter: new Date(cert.validTo).getTime() + }; + }); +} diff --git a/server/private/lib/blueprints/MtlsSchema.ts b/server/private/lib/blueprints/MtlsSchema.ts new file mode 100644 index 000000000..3ad89cf21 --- /dev/null +++ b/server/private/lib/blueprints/MtlsSchema.ts @@ -0,0 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { z } from "zod"; + +export const MtlsSchema = z.object({ + enabled: z.boolean().optional(), + "ca-certificates": z.array(z.string().min(1)).optional() +}); diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index a98368376..4c2b87906 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -18,6 +18,7 @@ import { domains, exitNodes, loginPage, + resourceMtlsCertificates, SiteResource, targetHealthCheck } from "@server/db"; @@ -146,6 +147,8 @@ export async function getTraefikConfig( maintenanceMessage: resources.maintenanceMessage, maintenanceEstimatedTime: resources.maintenanceEstimatedTime, + mtlsEnabled: resources.mtlsEnabled, + // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -293,7 +296,9 @@ export async function getTraefikConfig( maintenanceModeType: row.maintenanceModeType, maintenanceTitle: row.maintenanceTitle, maintenanceMessage: row.maintenanceMessage, - maintenanceEstimatedTime: row.maintenanceEstimatedTime + maintenanceEstimatedTime: row.maintenanceEstimatedTime, + + mtlsEnabled: row.mtlsEnabled }); } @@ -317,6 +322,31 @@ export async function getTraefikConfig( }); } + // Trusted client-CA certificates for resources that require mTLS. + const mtlsResourceIds = [ + ...new Set( + [...resourcesMap.values()] + .filter((r) => r.mode === "http" && r.mtlsEnabled) + .map((r) => r.resourceId as number) + ) + ]; + const mtlsCaCertsByResource = new Map(); + if (mtlsResourceIds.length > 0) { + const caRows = await db + .select({ + resourceId: resourceMtlsCertificates.resourceId, + certificate: resourceMtlsCertificates.certificate + }) + .from(resourceMtlsCertificates) + .where(inArray(resourceMtlsCertificates.resourceId, mtlsResourceIds)) + .orderBy(resourceMtlsCertificates.mtlsCertificateId); + for (const row of caRows) { + const list = mtlsCaCertsByResource.get(row.resourceId) ?? []; + list.push(row.certificate); + mtlsCaCertsByResource.set(row.resourceId, list); + } + } + // Group browser gateway targets by resource const browserGatewayResourcesMap = browserGatewayUiUrl ? buildBrowserGatewayResourcesMap( @@ -531,7 +561,7 @@ export async function getTraefikConfig( resource.pathMatchType ); - let tls = {}; + let tls: Record = {}; if (!pangolinCertModeEnabled) { tls = buildWildcardTls({ fullDomain, @@ -553,6 +583,59 @@ export async function getTraefikConfig( } } + // mTLS: Traefik resolves TLS options per hostname, and routers on + // the same host that reference different options make it fall back + // to the defaults, so the options are keyed on the resource (not + // the per-path router key) and every TLS router below (including + // the maintenance ones) references the same name. + const mtlsCaCerts = + resource.mtlsEnabled && resource.ssl + ? mtlsCaCertsByResource.get(resource.resourceId) + : undefined; + if (mtlsCaCerts && mtlsCaCerts.length > 0) { + const mtlsOptionsName = `resource-${resource.resourceId}-mtls`; + const mtlsHeadersMiddlewareName = `resource-${resource.resourceId}-mtls-headers`; + + if (!config_output.tls) { + config_output.tls = {}; + } + if (!config_output.tls.options) { + config_output.tls.options = {}; + } + // caFiles entries are Traefik FileOrContent values: anything + // that isn't an existing file path is read as inline PEM, so + // the CA certs travel in this config to any exit node. + config_output.tls.options[mtlsOptionsName] = { + clientAuth: { + caFiles: mtlsCaCerts, + clientAuthType: "RequireAndVerifyClientCert" + } + }; + + config_output.http.middlewares[mtlsHeadersMiddlewareName] = { + passTLSClientCert: { + pem: false, + info: { + notAfter: true, + notBefore: true, + serialNumber: true, + sans: true, + subject: { + commonName: true, + organization: true + }, + issuer: { + commonName: true, + organization: true + } + } + } + }; + routerMiddlewares.unshift(mtlsHeadersMiddlewareName); + + tls = { ...tls, options: mtlsOptionsName }; + } + config_output.http.services![serviceName] = { loadBalancer: { servers: buildHttpLoadBalancerServers(targets), diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index b7487fe49..b26d6afe5 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -303,6 +303,10 @@ async function disableFeature( await disablemaintenancePage(orgId); break; + case TierFeature.Mtls: + await disableMtls(orgId); + break; + case TierFeature.DevicePosture: await disableDevicePosture(orgId); break; @@ -519,6 +523,18 @@ async function disablemaintenancePage(orgId: string): Promise { logger.info(`Disabled maintenance page on all resources for org ${orgId}`); } +// The uploaded CA certificates are kept so mTLS can be re-enabled on upgrade. +async function disableMtls(orgId: string): Promise { + await db + .update(resources) + .set({ + mtlsEnabled: false + }) + .where(eq(resources.orgId, orgId)); + + logger.info(`Disabled mTLS on all resources for org ${orgId}`); +} + async function disableDevicePosture(orgId: string): Promise {} async function disableTwoFactorEnforcement(orgId: string): Promise { diff --git a/server/routers/external.ts b/server/routers/external.ts index 33d1fea6c..432bdefb0 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -834,6 +834,28 @@ authenticated.delete( resource.deleteResourceRule ); +authenticated.put( + "/resource/:resourceId/mtls/certificate", + verifyResourceAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createResourceMtlsCertificate), + logActionAudit(ActionsEnum.createResourceMtlsCertificate), + resource.createResourceMtlsCertificate +); +authenticated.get( + "/resource/:resourceId/mtls/certificates", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceMtlsCertificates), + resource.listResourceMtlsCertificates +); +authenticated.delete( + "/resource/:resourceId/mtls/certificate/:mtlsCertificateId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.deleteResourceMtlsCertificate), + logActionAudit(ActionsEnum.deleteResourceMtlsCertificate), + resource.deleteResourceMtlsCertificate +); + authenticated.get( "/target/:targetId", verifyTargetAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index bcf5a6a32..a19661002 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -737,6 +737,39 @@ authenticated.delete( resource.deleteResourceRule ); +authenticated.put( + [ + "/resource/:resourceId/mtls/certificate", + "/public-resource/:resourceId/mtls/certificate" + ], + verifyApiKeyResourceAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createResourceMtlsCertificate), + logActionAudit(ActionsEnum.createResourceMtlsCertificate), + resource.createResourceMtlsCertificate +); + +authenticated.get( + [ + "/resource/:resourceId/mtls/certificates", + "/public-resource/:resourceId/mtls/certificates" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceMtlsCertificates), + resource.listResourceMtlsCertificates +); + +authenticated.delete( + [ + "/resource/:resourceId/mtls/certificate/:mtlsCertificateId", + "/public-resource/:resourceId/mtls/certificate/:mtlsCertificateId" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResourceMtlsCertificate), + logActionAudit(ActionsEnum.deleteResourceMtlsCertificate), + resource.deleteResourceMtlsCertificate +); + authenticated.get( "/target/:targetId", verifyApiKeyTargetAccess, diff --git a/server/routers/resource/createResourceMtlsCertificate.ts b/server/routers/resource/createResourceMtlsCertificate.ts new file mode 100644 index 000000000..e701dd6a3 --- /dev/null +++ b/server/routers/resource/createResourceMtlsCertificate.ts @@ -0,0 +1,239 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +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 { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + MAX_MTLS_CERTIFICATES_PER_RESOURCE, + parseCaCertificates +} from "@server/lib/mtlsCertificate"; + +const createResourceMtlsCertificateParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +const createResourceMtlsCertificateBodySchema = z.strictObject({ + name: z.string().min(1).max(255).optional(), + certificate: z.string().min(1).max(100_000) +}); + +export type CreateResourceMtlsCertificateResponse = { + certificates: { + mtlsCertificateId: number; + resourceId: number; + name: string | null; + subject: string | null; + issuer: string | null; + serialNumber: string | null; + fingerprint: string | null; + notBefore: number | null; + notAfter: number | null; + createdAt: number; + }[]; +}; + +const description = + "Add a trusted CA certificate that clients must present a certificate signed by when mTLS is enabled on a resource. Accepts a PEM bundle; each certificate in the bundle is stored separately."; + +registry.registerPath({ + method: "put", + path: "/resource/{resourceId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: createResourceMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource/{resourceId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: createResourceMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +export async function createResourceMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + createResourceMtlsCertificateParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createResourceMtlsCertificateBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { name, certificate } = parsedBody.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (resource.mode !== "http") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS is only supported on HTTP resources" + ) + ); + } + + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.mtls + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + + let parsedCerts; + try { + parsedCerts = parseCaCertificates(certificate); + } catch (e) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + e instanceof Error ? e.message : "Invalid certificate" + ) + ); + } + + const existing = await db + .select({ fingerprint: resourceMtlsCertificates.fingerprint }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); + const newCerts = parsedCerts.filter( + (c) => !existingFingerprints.has(c.fingerprint) + ); + + if (newCerts.length === 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + parsedCerts.length === 1 + ? "This CA certificate has already been added to this resource" + : "All certificates in this bundle have already been added to this resource" + ) + ); + } + + if ( + existing.length + newCerts.length > + MAX_MTLS_CERTIFICATES_PER_RESOURCE + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates` + ) + ); + } + + const now = Date.now(); + const inserted = await db + .insert(resourceMtlsCertificates) + .values( + newCerts.map((c) => ({ + resourceId, + // a label only makes sense for a single certificate + name: newCerts.length === 1 ? (name ?? null) : null, + certificate: c.certificate, + subject: c.subject, + issuer: c.issuer, + serialNumber: c.serialNumber, + fingerprint: c.fingerprint, + notBefore: c.notBefore, + notAfter: c.notAfter, + createdAt: now + })) + ) + .returning({ + mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId, + resourceId: resourceMtlsCertificates.resourceId, + name: resourceMtlsCertificates.name, + subject: resourceMtlsCertificates.subject, + issuer: resourceMtlsCertificates.issuer, + serialNumber: resourceMtlsCertificates.serialNumber, + fingerprint: resourceMtlsCertificates.fingerprint, + notBefore: resourceMtlsCertificates.notBefore, + notAfter: resourceMtlsCertificates.notAfter, + createdAt: resourceMtlsCertificates.createdAt + }); + + return response(res, { + data: { certificates: inserted }, + success: true, + error: false, + message: "CA certificate added successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/deleteResourceMtlsCertificate.ts b/server/routers/resource/deleteResourceMtlsCertificate.ts new file mode 100644 index 000000000..c4b5ed178 --- /dev/null +++ b/server/routers/resource/deleteResourceMtlsCertificate.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { and, count, eq } from "drizzle-orm"; +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"; + +const deleteResourceMtlsCertificateParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive(), + mtlsCertificateId: z.coerce.number().int().positive() +}); + +const description = + "Remove a trusted CA certificate from a resource. If it is the last one, mTLS is disabled on the resource since it can no longer verify any client."; + +registry.registerPath({ + method: "delete", + path: "/resource/{resourceId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: deleteResourceMtlsCertificateParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "delete", + path: "/public-resource/{resourceId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: deleteResourceMtlsCertificateParamsSchema + }, + responses: {} +}); + +export type DeleteResourceMtlsCertificateResponse = { + // true when removing the last CA certificate forced mTLS off + mtlsDisabled: boolean; +}; + +export async function deleteResourceMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + deleteResourceMtlsCertificateParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourceId, mtlsCertificateId } = parsedParams.data; + + const [existing] = await db + .select() + .from(resourceMtlsCertificates) + .where( + and( + eq( + resourceMtlsCertificates.mtlsCertificateId, + mtlsCertificateId + ), + eq(resourceMtlsCertificates.resourceId, resourceId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `CA certificate with ID ${mtlsCertificateId} not found` + ) + ); + } + + let mtlsDisabled = false; + await db.transaction(async (trx) => { + await trx + .delete(resourceMtlsCertificates) + .where( + eq( + resourceMtlsCertificates.mtlsCertificateId, + mtlsCertificateId + ) + ); + + const [{ remaining }] = await trx + .select({ remaining: count() }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + // With no CA left there is nothing to verify clients against, so + // leaving mTLS "enabled" would either lock everyone out or be + // silently ignored by the Traefik config builder. + if (remaining === 0) { + const updated = await trx + .update(resources) + .set({ mtlsEnabled: false }) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.mtlsEnabled, true) + ) + ) + .returning({ resourceId: resources.resourceId }); + mtlsDisabled = updated.length > 0; + } + }); + + return response(res, { + data: { mtlsDisabled }, + success: true, + error: false, + message: "CA certificate removed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index a09cf0452..0dcb8099d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -21,6 +21,9 @@ export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; +export * from "./createResourceMtlsCertificate"; +export * from "./deleteResourceMtlsCertificate"; +export * from "./listResourceMtlsCertificates"; export * from "./getUserResources"; export * from "./listUserResourceAliases"; export * from "./setResourceHeaderAuth"; diff --git a/server/routers/resource/listResourceMtlsCertificates.ts b/server/routers/resource/listResourceMtlsCertificates.ts new file mode 100644 index 000000000..41ca46430 --- /dev/null +++ b/server/routers/resource/listResourceMtlsCertificates.ts @@ -0,0 +1,111 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { asc, eq } from "drizzle-orm"; +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"; + +const listResourceMtlsCertificatesParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +function queryResourceMtlsCertificates(resourceId: number) { + return db + .select({ + mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId, + resourceId: resourceMtlsCertificates.resourceId, + name: resourceMtlsCertificates.name, + subject: resourceMtlsCertificates.subject, + issuer: resourceMtlsCertificates.issuer, + serialNumber: resourceMtlsCertificates.serialNumber, + fingerprint: resourceMtlsCertificates.fingerprint, + notBefore: resourceMtlsCertificates.notBefore, + notAfter: resourceMtlsCertificates.notAfter, + createdAt: resourceMtlsCertificates.createdAt + }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)) + .orderBy(asc(resourceMtlsCertificates.mtlsCertificateId)); +} + +export type ListResourceMtlsCertificatesResponse = { + certificates: Awaited>; +}; + +const description = "List the trusted CA certificates for a resource's mTLS."; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: listResourceMtlsCertificatesParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: listResourceMtlsCertificatesParamsSchema + }, + responses: {} +}); + +export async function listResourceMtlsCertificates( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + listResourceMtlsCertificatesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + const certificates = await queryResourceMtlsCertificates(resourceId); + + return response(res, { + data: { certificates }, + success: true, + error: false, + message: "CA certificates 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/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 22ef89115..2716d8556 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -6,6 +6,7 @@ import { loginPage, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceMtlsCertificates, resourcePassword, resourcePincode, resourceRules, @@ -24,7 +25,7 @@ import { resourcePolicies, resources } from "@server/db"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, count } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -117,6 +118,12 @@ const updateHttpResourceBodySchema = z maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(), + mtlsEnabled: z + .boolean() + .optional() + .describe( + "Require clients to present a certificate signed by one of the resource's trusted CA certificates. Requires SSL and at least one CA certificate." + ), postAuthPath: z.string().nullable().optional(), // SSH settings pamMode: z.enum(["passthrough", "push"]).optional(), @@ -508,8 +515,58 @@ async function updateHttpResource( } } + const effectiveSsl = updateData.ssl ?? resource.ssl; + const effectiveMtlsEnabled = updateData.mtlsEnabled ?? resource.mtlsEnabled; + + if (updateData.mtlsEnabled === true) { + if (resource.mode !== "http") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS is only supported on HTTP resources." + ) + ); + } + + const isMtlsLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.mtls + ); + if (!isMtlsLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + + const [{ certificateCount }] = await db + .select({ certificateCount: count() }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resource.resourceId)); + + if (certificateCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Add at least one trusted CA certificate before enabling mTLS." + ) + ); + } + } + + if (effectiveMtlsEnabled && !effectiveSsl) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS requires SSL to be enabled for this resource. Disable mTLS before turning SSL off." + ) + ); + } + // catch when the resource policy changes or gets cleared - if (updateData.resourcePolicyId !== undefined && + if (updateData.resourcePolicyId !== undefined && resource.resourcePolicyId !== updateData.resourcePolicyId) { await clearResourceSpecificSettings( resource.resourceId, diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index b5a36c56c..2bee1be7b 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -111,6 +111,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/public/{niceId}/maintenance` }); } + + if (!env.flags.disableEnterpriseFeatures && resource.mode === "http") { + navItems.push({ + title: t("mtls"), + href: `/{orgId}/settings/resources/public/{niceId}/mtls` + }); + } } if (resource.mode === "inference") { diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx new file mode 100644 index 000000000..da0220e07 --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Badge } from "@app/components/ui/badge"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { resourceQueries } from "@app/lib/queries"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { + CreateResourceMtlsCertificateResponse, + DeleteResourceMtlsCertificateResponse, + UpdateResourceResponse +} from "@server/routers/resource"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { AlertCircle, FileKey2, Info, ShieldCheck, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const EXPIRES_SOON_MS = 30 * DAY_MS; + +export default function ResourceMtlsPage() { + const params = useParams(); + const router = useRouter(); + const { env } = useEnvContext(); + const { resource, updateResource } = useResourceContext(); + const t = useTranslations(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { isPaidUser } = usePaidStatus(); + + const supportsMtls = resource.mode === "http"; + const isMtlsDisabled = !isPaidUser(tierMatrix.mtls); + + useEffect(() => { + if (env.flags.disableEnterpriseFeatures || !supportsMtls) { + router.replace( + `/${params.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [ + env.flags.disableEnterpriseFeatures, + params.orgId, + resource.niceId, + router, + supportsMtls + ]); + + const { data: certificates = [], isLoading: isLoadingCertificates } = + useQuery( + resourceQueries.resourceMtlsCertificates({ + resourceId: resource.resourceId + }) + ); + + const [name, setName] = useState(""); + const [pem, setPem] = useState(""); + const [adding, setAdding] = useState(false); + const [removingId, setRemovingId] = useState(null); + const fileInputRef = useRef(null); + + const [mtlsEnabled, setMtlsEnabled] = useState(resource.mtlsEnabled); + const [saving, setSaving] = useState(false); + + // The server switches mTLS off when the last CA is removed. + useEffect(() => { + setMtlsEnabled(resource.mtlsEnabled); + }, [resource.mtlsEnabled]); + + if (env.flags.disableEnterpriseFeatures || !supportsMtls) { + return null; + } + + const hasCertificates = certificates.length > 0; + const canEnable = hasCertificates && resource.ssl; + + async function invalidateCertificates() { + await queryClient.invalidateQueries( + resourceQueries.resourceMtlsCertificates({ + resourceId: resource.resourceId + }) + ); + } + + async function onFileSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + const text = await file.text(); + setPem((prev) => (prev.trim() ? `${prev.trim()}\n${text}` : text)); + if (!name && certificates.length === 0) { + setName(file.name.replace(/\.(pem|crt|cer|ca-bundle)$/i, "")); + } + } + + async function onAddCertificate() { + if (!pem.trim()) return; + setAdding(true); + try { + await api.put>( + `/resource/${resource.resourceId}/mtls/certificate`, + { + certificate: pem, + ...(name.trim() ? { name: name.trim() } : {}) + } + ); + setPem(""); + setName(""); + await invalidateCertificates(); + toast({ + title: t("mtlsCertificateAdded"), + description: t("mtlsCertificateAddedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("mtlsCertificateErrorAdd"), + description: formatAxiosError( + e, + t("mtlsCertificateErrorAddDescription") + ) + }); + } finally { + setAdding(false); + } + } + + async function onRemoveCertificate(mtlsCertificateId: number) { + setRemovingId(mtlsCertificateId); + try { + const res = await api.delete< + AxiosResponse + >( + `/resource/${resource.resourceId}/mtls/certificate/${mtlsCertificateId}` + ); + await invalidateCertificates(); + + if (res.data.data.mtlsDisabled) { + updateResource({ mtlsEnabled: false }); + toast({ + title: t("mtlsCertificateRemoved"), + description: t("mtlsLastCertificateRemoved") + }); + } else { + toast({ title: t("mtlsCertificateRemoved") }); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("mtlsCertificateErrorRemove"), + description: formatAxiosError( + e, + t("mtlsCertificateErrorRemoveDescription") + ) + }); + } finally { + setRemovingId(null); + } + } + + async function onSaveEnforcement() { + setSaving(true); + try { + await api.post>( + `/resource/${resource.resourceId}`, + { mtlsEnabled } + ); + updateResource({ mtlsEnabled }); + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourceErrorUpdate"), + description: formatAxiosError( + e, + t("resourceErrorUpdateDescription") + ) + }); + } finally { + setSaving(false); + } + } + + return ( + <> + +
+ + + + + {t("mtlsCaCertificates")} + + + {t("mtlsCaCertificatesDescription")} + + + + +
+ {isLoadingCertificates ? null : hasCertificates ? ( +
    + {certificates.map((cert) => { + const now = Date.now(); + const expired = + cert.notAfter != null && + cert.notAfter < now; + const expiresSoon = + !expired && + cert.notAfter != null && + cert.notAfter - now < + EXPIRES_SOON_MS; + return ( +
  • +
    + +
    +
    + + {cert.name || + cert.subject || + t( + "mtlsCaCertificates" + )} + + {expired && ( + + {t( + "mtlsExpired" + )} + + )} + {expiresSoon && ( + + {t( + "mtlsExpiresSoon" + )} + + )} +
    + {cert.name && + cert.subject && ( +

    + { + cert.subject + } +

    + )} + {cert.issuer && ( +

    + {t( + "mtlsIssuedBy", + { + issuer: cert.issuer + } + )} +

    + )} + {cert.notAfter != + null && ( +

    + {t( + "mtlsExpiresOn", + { + date: new Date( + cert.notAfter + ).toLocaleDateString() + } + )} +

    + )} + {cert.fingerprint && ( +

    + { + cert.fingerprint + } +

    + )} +
    +
    + +
  • + ); + })} +
+ ) : ( +

+ {t("mtlsNoCertificates")} +

+ )} + +
+
+ + + setName(e.target.value) + } + /> +
+
+ +