From 080bcbaf97cc383324cf4dd3a6905aaac36c2614 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 7 Sep 2026 11:55:45 -0400 Subject: [PATCH 01/48] 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 02/48] 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 03/48] 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 04/48] 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 05/48] 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 06/48] 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 07/48] =?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 08/48] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20create=20DB=20mod?= =?UTF-8?q?els?= 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 09/48] =?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 10/48] =?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 11/48] =?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 12/48] =?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 13/48] =?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 14/48] =?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 15/48] =?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 16/48] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20order=20by=20id=20de?= =?UTF-8?q?sc?= 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 24/48] =?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 25/48] =?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 26/48] =?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 27/48] =?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 28/48] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Add=20`ssl`=20field?= =?UTF-8?q?=20to=20`redirects`=20and=20add=20traefik=20config=20to=20publi?= =?UTF-8?q?c=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 29/48] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20destination?= =?UTF-8?q?Domain=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 30/48] =?UTF-8?q?=E2=9A=B0=EF=B8=8F=20remove=20useless=20u?= =?UTF-8?q?seffect?= 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 31/48] =?UTF-8?q?=F0=9F=9A=B8=20redirect=20to=20list=20aft?= =?UTF-8?q?er=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 32/48] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Force=20disable=20re?= =?UTF-8?q?directs=20when=20domain=20is=20missing=20attached=20resource=20?= =?UTF-8?q?domain?= 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 33/48] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20fix=20types=20and=20?= =?UTF-8?q?validate=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 34/48] =?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 35/48] =?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 dfe4314a5b4ff465dec491ffba18b56bbd3f0591 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 25 Sep 2026 21:10:52 +0200 Subject: [PATCH 36/48] =?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/routers/idp/validateOidcCallback.ts | 65 +++++++++++----------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 57b16964d..1498e23a7 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -1,45 +1,44 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db, Org, primaryDb } from "@server/db"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { - idp, - idpOidcConfig, - idpOrg, - orgs, - Role, - roles, - userOrgRoles, - userOrgs, - users -} from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; -import * as arctic from "arctic"; -import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; -import jmespath from "jmespath"; -import jsonwebtoken from "jsonwebtoken"; -import config from "@server/lib/config"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; import { createSession, generateId, generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; -import { decrypt } from "@server/lib/crypto"; -import { UserType } from "@server/types/UserTypes"; -import { LimitId } from "@server/lib/billing"; -import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; -import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; -import { isSubscribed } from "#dynamic/lib/isSubscribed"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { + db, + idp, + idpOidcConfig, + idpOrg, + Org, + orgs, + roles, + userOrgRoles, + userOrgs, + users +} from "@server/db"; +import { LimitId } from "@server/lib/billing"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { usageService } from "@server/lib/billing/usageService"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import response from "@server/lib/response"; import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg"; -import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { UserType } from "@server/types/UserTypes"; +import * as arctic from "arctic"; +import { and, eq, inArray } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import jmespath from "jmespath"; +import jsonwebtoken from "jsonwebtoken"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const ensureTrailingSlash = (url: string): string => { return url; From 59d900a1c6a4677912ae708d683e0a1f88ba6e7d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 2 Oct 2026 16:28:43 -0400 Subject: [PATCH 37/48] remove cert from table and add copy text --- src/app/[orgId]/settings/redirects/page.tsx | 36 --------- src/components/RedirectsTable.tsx | 84 ++++++++------------- 2 files changed, 31 insertions(+), 89 deletions(-) diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 434b0d470..1f2d4a7d6 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -2,8 +2,6 @@ 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 { build } from "@server/build"; -import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { ListRedirectsResponse } from "@server/routers/redirect"; import type { AxiosResponse } from "axios"; import type { Metadata } from "next"; @@ -66,39 +64,6 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { baseDomain: redirect.baseDomain })); - // Prefetched in one batched call so the table doesn't fire a separate - // certificate request per visible row once it mounts on the client. - const certDomains = Array.from( - new Set( - redirectRows - .map((r) => { - const domainHost = r.baseDomain - ? [r.subdomain, r.baseDomain].filter(Boolean).join(".") - : null; - return r.resourceFullDomain ?? domainHost; - }) - .filter((host): host is string => Boolean(host)) - ) - ); - - let initialCertificates: GetBatchedCertificateResponse | undefined; - if (build !== "oss" && certDomains.length > 0) { - try { - const certSearchParams = new URLSearchParams( - certDomains.map((domain) => ["domains", domain]) - ); - const certRes = await internal.get< - AxiosResponse - >( - `/org/${orgId}/batched-certificates?${certSearchParams.toString()}`, - await authCookieHeader() - ); - initialCertificates = certRes.data.data; - } catch { - // leave undefined so each row falls back to fetching its own - } - } - return ( <> ); diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 8e2714d66..d51a88c66 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -1,6 +1,7 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import CopyToClipboard from "@app/components/CopyToClipboard"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { @@ -14,12 +15,10 @@ import { ControlledDataTable, type ExtendedColumnDef } from "@app/components/ui/controlled-data-table"; -import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; 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 { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown, @@ -72,15 +71,13 @@ type RedirectsTableProps = { orgId: string; pagination: PaginationState; rowCount: number; - initialCertificates?: GetBatchedCertificateResponse; }; export default function RedirectsTable({ redirects, orgId, pagination, - rowCount, - initialCertificates + rowCount }: RedirectsTableProps) { const router = useRouter(); const t = useTranslations(); @@ -259,49 +256,28 @@ export default function RedirectsTable({ .join(".") : null; const host = redirect.resourceFullDomain ?? domainHost; - // The cert lives on whichever domain actually terminates - // TLS: the resource's domain when attached to a resource, - // otherwise the redirect's own domain. - const certDomainId = - redirect.resourceDomainId ?? redirect.domainId; if (redirect.resourceId && !redirect.resourceDomainId) { return ( -
- -
+ ); } - return ( -
- {certDomainId && host ? ( - - ) : null} - - {host ?? ""} - {redirect.matchPath && ( - - {redirect.pathMatchType === "prefix" - ? withPrefixGlob(redirect.matchPath) - : redirect.matchPath} - - )} - -
- ); + const matchPath = redirect.matchPath + ? redirect.pathMatchType === "prefix" + ? withPrefixGlob(redirect.matchPath) + : redirect.matchPath + : ""; + const source = `${host ?? ""}${matchPath}`; + + if (!source) { + return -; + } + + return ; } }, { @@ -313,17 +289,19 @@ export default function RedirectsTable({ ), cell: ({ row }) => { const redirect = row.original; + const rewritePath = redirect.rewritePath + ? redirect.rewritePathType === "prefix" + ? withPrefixGlob(redirect.rewritePath) + : redirect.rewritePath + : ""; + const destination = `${redirect.destinationHost}${rewritePath}`; + + if (!destination) { + return -; + } + return ( - - {redirect.destinationHost} - {redirect.rewritePath && ( - - {redirect.rewritePathType === "prefix" - ? withPrefixGlob(redirect.rewritePath) - : redirect.rewritePath} - - )} - + ); } }, @@ -419,7 +397,7 @@ export default function RedirectsTable({ ) } ], - [orgId, t, initialCertificates] + [orgId, t] ); return ( From daf8cf16a30b1c160406aaea8621e0d35aae4990 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 2 Oct 2026 16:30:52 -0400 Subject: [PATCH 38/48] remove danger zone --- messages/en-US.json | 2 +- src/components/RedirectForm.tsx | 1186 +++++++++++++++---------------- 2 files changed, 559 insertions(+), 629 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 39327ae7b..10b20c4fd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4441,7 +4441,7 @@ "redirectDestinationHost": "Destination", "redirectDestinationHostDescription": "Where requests are sent, including the scheme, such as https://example.com", "redirectDestinationHostRequired": "Enter a destination", - "redirectSameDomainAsSource": "Same domain as source", + "redirectSameDomainAsSource": "Same Domain as Source", "redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path", "redirectDestinationHostInvalid": "Enter a valid destination with a scheme, such as https://example.com", "redirectMatchPathDescription": "Which incoming paths this redirect applies to", diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index f57e6b9c3..e8c703e23 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -1,6 +1,5 @@ "use client"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import DomainPicker from "@app/components/DomainPicker"; import { PathMatchDisplay, @@ -15,7 +14,6 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, - SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle @@ -101,8 +99,6 @@ export default function RedirectForm({ const t = useTranslations(); const [, formAction, saveLoading] = useActionState(onSubmit, null); - const [deleteLoading, setDeleteLoading] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(initialResource); @@ -320,96 +316,301 @@ export default function RedirectForm({ } } - 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("redirectSource")} + + + {t("redirectSourceSectionDescription")} + + - - - - - {t("redirectSource")} - - - {t("redirectSourceSectionDescription")} - - - - - - - - - - {resourceMissingDomain && ( - - - - {t( - "redirectDomainSelectedDomainNotFound" - )} - - + + + + + + + {resourceMissingDomain && ( + + + + {t( + "redirectDomainSelectedDomainNotFound" + )} + + + )} + ( + + + + + + )} + /> + + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t( + "redirectAttachedTo" + )} + + + + {t( + "redirectAttachedToDescription" + )} + + + + )} + /> + + {attachTo === "domain" ? ( + ( + + { + form.setValue( + "domainId", + res?.domainId ?? + null, + { + shouldValidate: true + } + ); + form.setValue( + "subdomain", + res?.subdomain || + null + ); + setDomainFullDomain( + res?.fullDomain ?? + null + ); + }} + /> + + + )} + /> + + ) : ( + + ( + + + {t( + "selectedRedirectResource" + )} + + + + + + + + + { + setSelectedResource( + resource + ); + field.onChange( + resource.resourceId + ); + }} + /> + + + + + )} + /> + + )} + + {attachTo === "resource" && ( + + + + {t("resourceDomain")} + + + + + )} + + {/* The cloud only serves HTTPS, so there is nothing to toggle there. */} + {build !== "saas" && ( + + ( + )} + + + + + + - - ( - - - {t("name")} - - - - - - - )} - /> - + + + + {t("redirectSettings")} + + + {t("redirectSettingsDescription")} + + - - ( - - - {t( - "redirectAttachedTo" - )} - - - - {t( - "redirectAttachedToDescription" - )} - - - - )} - /> - - {attachTo === "domain" ? ( - - ( - - { - form.setValue( - "domainId", - res?.domainId ?? - null, - { - shouldValidate: true - } - ); - form.setValue( - "subdomain", - res?.subdomain || - null - ); - setDomainFullDomain( - res?.fullDomain ?? - null - ); - }} - /> - - - )} - /> - - ) : ( - - ( - - - {t( - "selectedRedirectResource" - )} - - - - - - - - - { - setSelectedResource( - resource - ); - field.onChange( - resource.resourceId - ); - }} - /> - - - - - )} - /> - - )} + + +
+ + + + + - {attachTo === "resource" && ( - + + ( - {t("resourceDomain")} + {t( + "redirectDestinationHost" + )} - + + + + {t( + "redirectDestinationHostDescription" + )} + + + + )} + /> + + + + ( + + + {t("matchPath")} + + { + // No match path + // means the + // redirect applies + // to every path; + // pathMatchType is + // NOT NULL so it + // keeps a default. + field.onChange( + config.path || + null + ); + form.setValue( + "pathMatchType", + (config.pathMatchType as + | "exact" + | "prefix" + | "regex") || + DEFAULT_PATH_MATCH_TYPE + ); + }} + trigger={ + } /> + + {t( + "redirectMatchPathDescription" + )} + + - - )} + )} + /> + - {/* The cloud only serves HTTPS, so there is nothing to toggle there. */} - {build !== "saas" && ( - - ( - - - - - - - )} - /> - - )} - -
- -
-
-
- - - - - {t("redirectSettings")} - - - {t("redirectSettingsDescription")} - - - - - -
- - - - - - - - ( - - - {t( - "redirectDestinationHost" - )} - - - - - - {t( - "redirectDestinationHostDescription" - )} - - - - )} - /> - - - - ( - - - {t("matchPath")} - - { - // No match path - // means the - // redirect applies - // to every path; - // pathMatchType is - // NOT NULL so it - // keeps a default. - field.onChange( - config.path || - null - ); - form.setValue( - "pathMatchType", - (config.pathMatchType as - | "exact" - | "prefix" - | "regex") || - DEFAULT_PATH_MATCH_TYPE - ); - }} - trigger={ + + ( + + + {t("rewritePath")} + + { + field.onChange( + config.rewritePath || + null + ); + form.setValue( + "rewritePathType", + (config.rewritePathType as + | "exact" + | "prefix" + | "regex" + | "stripPrefix" + | null) ?? + null + ); + }} + trigger={ + hasRewrite ? ( + ) : ( + - } - /> - - {t( - "redirectMatchPathDescription" - )} - - - - )} - /> - + ) + } + /> + + {t( + "redirectRewritePathDescription" + )} + + + + )} + /> + - - ( - - - {t("rewritePath")} - - { + + ( + + + {t("priority")} + + + field.onChange( - config.rewritePath || - null - ); - form.setValue( - "rewritePathType", - (config.rewritePathType as - | "exact" - | "prefix" - | "regex" - | "stripPrefix" - | null) ?? - null - ); - }} - trigger={ - hasRewrite ? ( - - ) : ( - + e.target + .valueAsNumber ) } /> - - {t( - "redirectRewritePathDescription" + + + {t( + "redirectPriorityDescription" + )} + + + + )} + /> + + + + ( + + + - - - )} - /> - - - - ( - - - {t("priority")} - - - - field.onChange( - e.target - .valueAsNumber - ) - } - /> - - - {t( - "redirectPriorityDescription" + description={t( + "redirectPermanentDescription" )} - - - - )} - /> - + checked={ + field.value + } + onCheckedChange={ + field.onChange + } + /> + + + + )} + /> + + +
+ +
+
+
- - ( - - - - - - - )} - /> - -
- - -
-
-
- - {isEditing && ( - - - - {t("dangerSection")} - - - - - - - )} - -
- - -
-
- +
+ + +
+ ); } From 9746ba5741110454f6b764dd7532d97a09ef82a3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 2 Oct 2026 16:41:56 -0400 Subject: [PATCH 39/48] add server side sort and filter --- server/routers/redirect/listRedirects.ts | 69 +++++++++++- src/components/RedirectsTable.tsx | 129 +++++++++++++++++++++-- 2 files changed, 184 insertions(+), 14 deletions(-) diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 0b214f4b5..c59d03b4e 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -65,7 +65,46 @@ const listSchema = z.object({ default: 1, description: "Page number to retrieve" }), - query: z.string().optional() + query: z.string().optional(), + sort_by: z + .enum(["name", "priority"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name", "priority"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + type: z + .enum(["permanent", "temporary"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["permanent", "temporary"], + description: + "Filter redirects by type. `permanent` is a 308 redirect and `temporary` is a 307 redirect." + }), + enabled: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter redirects based on enabled status" + }) }); registry.registerPath({ @@ -121,9 +160,20 @@ export async function listRedirects( ); } - const { pageSize, page, query } = parsedQuery.data; + const { pageSize, page, query, sort_by, order, type, enabled } = + parsedQuery.data; const conditions = [eq(redirects.orgId, orgId)]; + if (type === "permanent") { + conditions.push(eq(redirects.permanent, true)); + } else if (type === "temporary") { + conditions.push(eq(redirects.permanent, false)); + } + + if (typeof enabled !== "undefined") { + conditions.push(eq(redirects.enabled, enabled)); + } + if (query) { const term = "%" + query.toLowerCase() + "%"; conditions.push( @@ -172,12 +222,25 @@ export async function listRedirects( .as("filtered_redirects") ); + // Null priority is shown as 100, so sort on that same value. + const prioritySort = sql`COALESCE(${redirects.priority}, 100)`; + const orderBy = + sort_by === "name" + ? order === "asc" + ? [asc(redirects.name), desc(redirects.redirectId)] + : [desc(redirects.name), desc(redirects.redirectId)] + : sort_by === "priority" + ? order === "asc" + ? [asc(prioritySort), desc(redirects.redirectId)] + : [desc(prioritySort), desc(redirects.redirectId)] + : [desc(prioritySort), desc(redirects.redirectId)]; + const [totalCount, rows] = await Promise.all([ countQuery, baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) - .orderBy(desc(redirects.priority), desc(redirects.redirectId)) + .orderBy(...orderBy) ]); return response(res, { diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index d51a88c66..5b35aafeb 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -19,12 +19,16 @@ 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 { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown, + ArrowDown01Icon, ArrowRight, ArrowUp, + ArrowUp10Icon, ArrowUpRight, + ChevronsUpDownIcon, GlobeIcon, MinusIcon, MoreHorizontal, @@ -33,6 +37,7 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { z } from "zod"; import { useMemo, useOptimistic, @@ -42,8 +47,14 @@ import { type ComponentRef } from "react"; import { useDebouncedCallback } from "use-debounce"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { InfoPopup } from "./ui/info-popup"; +const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + export type RedirectRow = { redirectId: number; niceId: string; @@ -119,6 +130,26 @@ export default function RedirectsTable({ filter({ searchParams }); }, 300); + function handleFilterChange( + column: string, + value: string | undefined | null | string[] + ) { + searchParams.delete(column); + searchParams.delete("page"); + + if (typeof value === "string") { + searchParams.set(column, value); + } else if (value) { + value.forEach((val) => searchParams.append(column, val)); + } + + filter({ searchParams }); + } + + function toggleSort(column: string) { + filter({ searchParams: getNextSortOrder(column, searchParams) }); + } + // Prefix matches/rewrites cover everything beneath the path, so show the // implied glob rather than the bare prefix. function withPrefixGlob(path: string) { @@ -170,7 +201,27 @@ export default function RedirectsTable({ { accessorKey: "name", enableHiding: false, - header: () => {t("name")}, + friendlyName: t("name"), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + }, cell: ({ row }) => ( {t("identifier")}, - cell: ({ row }) => ( - {row.original.niceId} - ) + cell: ({ row }) => {row.original.niceId} }, { id: "attachedTo", @@ -308,7 +357,29 @@ export default function RedirectsTable({ { accessorKey: "priority", friendlyName: t("priority"), - header: () => {t("priority")}, + header: () => { + const priorityOrder = getSortDirection( + "priority", + searchParams + ); + const Icon = + priorityOrder === "asc" + ? ArrowDown01Icon + : priorityOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + }, cell: ({ row }) => { // 100 is the automatic default; anything else was set // deliberately, so flag which way it deviates. @@ -330,7 +401,28 @@ export default function RedirectsTable({ { accessorKey: "permanent", friendlyName: t("redirectType"), - header: () => {t("redirectType")}, + header: () => ( + + handleFilterChange("type", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("redirectType")} + className="p-3" + /> + ), cell: ({ row }) => ( {row.original.permanent @@ -342,7 +434,24 @@ export default function RedirectsTable({ { accessorKey: "enabled", friendlyName: t("enabled"), - header: () => {t("enabled")}, + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), cell: ({ row }) => ( Date: Fri, 2 Oct 2026 16:52:31 -0400 Subject: [PATCH 40/48] add descriptions to the rewrite modals --- messages/en-US.json | 3 +- src/components/PathMatchRenameModal.tsx | 204 +++++++++++++----------- 2 files changed, 117 insertions(+), 90 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 10b20c4fd..f74f72444 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3540,7 +3540,7 @@ "pathMatchExactHelp": "Example: /api matches only /api", "pathMatchRegexHelp": "Example: ^/api/.* matches /api/anything", "pathRewriteModalTitle": "Configure Path Rewriting", - "pathRewriteModalDescription": "Transform the matched path before forwarding to the target.", + "pathRewriteModalDescription": "Transform the matched path before forwarding to the destination.", "pathRewriteType": "Rewrite Type", "pathRewritePrefixOption": "Prefix - Replace prefix", "pathRewriteExactOption": "Exact - Replace entire path", @@ -3557,6 +3557,7 @@ "pathRewriteExact": "Exact", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Strip", + "pathRewriteStripPrefix": "Strip Prefix", "pathRewriteStripLabel": "strip", "sidebarEnableEnterpriseLicense": "Enable Enterprise License", "cannotbeUndone": "This can not be undone.", diff --git a/src/components/PathMatchRenameModal.tsx b/src/components/PathMatchRenameModal.tsx index 6474da6b9..7f9b30de7 100644 --- a/src/components/PathMatchRenameModal.tsx +++ b/src/components/PathMatchRenameModal.tsx @@ -1,11 +1,8 @@ import { Settings } from "lucide-react"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; + DescribedSelect, + type DescribedSelectOption +} from "@app/components/DescribedSelect"; import { Badge } from "@app/components/ui/badge"; import { Label } from "@app/components/ui/label"; @@ -24,6 +21,30 @@ import { } from "./Credenza"; import { useTranslations } from "next-intl"; +type PathMatchType = "prefix" | "exact" | "regex"; +type RewritePathType = "prefix" | "exact" | "regex" | "stripPrefix"; + +const pathMatchTypes: PathMatchType[] = ["prefix", "exact", "regex"]; + +function isPathMatchType( + value: string | null | undefined +): value is PathMatchType { + return pathMatchTypes.includes(value as PathMatchType); +} + +const rewritePathTypes: RewritePathType[] = [ + "prefix", + "exact", + "regex", + "stripPrefix" +]; + +function isRewritePathType( + value: string | null | undefined +): value is RewritePathType { + return rewritePathTypes.includes(value as RewritePathType); +} + export function PathMatchModal({ value, onChange, @@ -39,20 +60,42 @@ export function PathMatchModal({ const t = useTranslations(); const [open, setOpen] = useState(false); - const [matchType, setMatchType] = useState( - value?.pathMatchType || "prefix" + const [matchType, setMatchType] = useState( + isPathMatchType(value?.pathMatchType) ? value.pathMatchType : "prefix" ); const [path, setPath] = useState(value?.path || ""); + const matchOptions: DescribedSelectOption[] = [ + { + value: "prefix", + title: t("pathMatchPrefix"), + description: t("pathMatchPrefixHelp") + }, + { + value: "exact", + title: t("pathMatchExact"), + description: t("pathMatchExactHelp") + }, + { + value: "regex", + title: t("pathMatchRegex"), + description: t("pathMatchRegexHelp") + } + ]; + useEffect(() => { if (open) { - setMatchType(value?.pathMatchType || "prefix"); + setMatchType( + isPathMatchType(value?.pathMatchType) + ? value.pathMatchType + : "prefix" + ); setPath(value?.path || ""); } }, [open, value]); const handleSave = () => { - onChange({ pathMatchType: matchType as any, path: path.trim() }); + onChange({ pathMatchType: matchType, path: path.trim() }); setOpen(false); }; @@ -66,19 +109,6 @@ export function PathMatchModal({ ? t("pathMatchRegexPlaceholder") : t("pathMatchDefaultPlaceholder"); - const getHelpText = () => { - switch (matchType) { - case "prefix": - return t("pathMatchPrefixHelp"); - case "exact": - return t("pathMatchExactHelp"); - case "regex": - return t("pathMatchRegexHelp"); - default: - return ""; - } - }; - return ( {trigger} @@ -91,23 +121,21 @@ export function PathMatchModal({
- - + + + options={matchOptions} + value={matchType} + onChange={setMatchType} + searchPlaceholder={t("search")} + emptyMessage={t("noResults")} + /> +

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

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

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

From 4d1a14c9655fa972e00ac5f185ef27464c0e85b4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 2 Oct 2026 17:02:35 -0400 Subject: [PATCH 41/48] seperate attached to column --- messages/en-US.json | 5 + server/routers/redirect/listRedirects.ts | 37 ++- src/app/[orgId]/settings/redirects/page.tsx | 67 +++- src/components/RedirectsTable.tsx | 350 +++++++++++++++++--- 4 files changed, 406 insertions(+), 53 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f74f72444..d1486cbe6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2438,6 +2438,7 @@ "domains": "Domains", "domainsDescription": "Create and manage domains available in the organization", "domainsSearch": "Search domains...", + "domainsNotFound": "No domains found", "domainAdd": "Add Domain", "domainAddDescription": "Register a new domain with to the organization", "domainCreate": "Create Domain", @@ -4428,6 +4429,10 @@ "redirectSource": "Source", "redirectDestination": "Destination", "redirectAttachedTo": "Attached To", + "redirectFilterAnyDomain": "All domains", + "redirectFilterAnyResource": "All resources", + "redirectFilterDomainFallback": "Domain {id}", + "redirectFilterResourceFallback": "Resource {id}", "redirectType": "Type", "redirectTypePermanent": "Permanent (308)", "redirectTypeTemporary": "Temporary (307)", diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index c59d03b4e..518d27298 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -104,7 +104,21 @@ const listSchema = z.object({ .openapi({ type: "boolean", description: "Filter redirects based on enabled status" - }) + }), + resourceId: z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined) + .openapi({ + type: "integer", + description: "Filter redirects attached to this resource" + }), + domainId: z.string().nonempty().optional().catch(undefined).openapi({ + type: "string", + description: "Filter redirects attached to this domain" + }) }); registry.registerPath({ @@ -160,8 +174,17 @@ export async function listRedirects( ); } - const { pageSize, page, query, sort_by, order, type, enabled } = - parsedQuery.data; + const { + pageSize, + page, + query, + sort_by, + order, + type, + enabled, + resourceId, + domainId + } = parsedQuery.data; const conditions = [eq(redirects.orgId, orgId)]; if (type === "permanent") { @@ -174,6 +197,14 @@ export async function listRedirects( conditions.push(eq(redirects.enabled, enabled)); } + if (typeof resourceId !== "undefined") { + conditions.push(eq(redirects.resourceId, resourceId)); + } + + if (typeof domainId !== "undefined") { + conditions.push(eq(redirects.domainId, domainId)); + } + if (query) { const term = "%" + query.toLowerCase() + "%"; conditions.push( diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 1f2d4a7d6..7cc04837b 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -1,8 +1,14 @@ -import RedirectsTable from "@app/components/RedirectsTable"; +import RedirectsTable, { + type RedirectFilterDomain +} from "@app/components/RedirectsTable"; +import type { SelectedResource } from "@app/components/resource-selector"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; import type { ListRedirectsResponse } from "@server/routers/redirect"; +import { GetResourceResponse } from "@server/routers/resource/getResource"; +import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -18,10 +24,18 @@ type RedirectIndexPageProps = { export const dynamic = "force-dynamic"; +function parsePositiveInt(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return undefined; + return parsed; +} + export default async function RedirectIndexPage(props: RedirectIndexPageProps) { const { orgId } = await props.params; const searchParams = new URLSearchParams(await props.searchParams); const t = await getTranslations(); + const header = await authCookieHeader(); let redirects: ListRedirectsResponse["redirects"] = []; let pagination: ListRedirectsResponse["pagination"] = { @@ -33,7 +47,7 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { try { const res = await internal.get>( `/org/${orgId}/redirects?${searchParams.toString()}`, - await authCookieHeader() + header ); const responseData = res.data.data; redirects = responseData.redirects; @@ -42,6 +56,53 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { // empty list on error } + const resourceIdParam = parsePositiveInt(searchParams.get("resourceId")); + let initialFilterResource: SelectedResource | null = null; + if (resourceIdParam) { + try { + const resourceRes = await internal.get( + `/resource/${resourceIdParam}`, + header + ); + const resource = ( + resourceRes.data as ResponseT + ).data; + if (resource && resource.orgId === orgId) { + initialFilterResource = { + name: resource.name, + resourceId: resource.resourceId, + fullDomain: resource.fullDomain, + niceId: resource.niceId, + ssl: resource.ssl, + wildcard: resource.wildcard + }; + } + } catch { + // leave null so the table falls back to the id + } + } + + const domainIdParam = searchParams.get("domainId"); + let initialFilterDomain: RedirectFilterDomain | null = null; + if (domainIdParam) { + try { + const domainRes = await internal.get( + `/org/${orgId}/domain/${domainIdParam}`, + header + ); + const domain = (domainRes.data as ResponseT) + .data; + if (domain) { + initialFilterDomain = { + domainId: domain.domainId, + baseDomain: domain.baseDomain + }; + } + } catch { + // leave null so the table falls back to the id + } + } + const redirectRows = redirects.map((redirect) => ({ redirectId: redirect.redirectId, niceId: redirect.niceId, @@ -79,6 +140,8 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterResource={initialFilterResource} + initialFilterDomain={initialFilterDomain} /> ); diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 5b35aafeb..2918c1e09 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -2,24 +2,45 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + ResourceSelector, + type SelectedResource +} from "@app/components/resource-selector"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Switch } from "@app/components/ui/switch"; import { - ControlledDataTable, - type ExtendedColumnDef -} from "@app/components/ui/controlled-data-table"; + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown, @@ -28,11 +49,11 @@ import { ArrowUp, ArrowUp10Icon, ArrowUpRight, + CheckIcon, ChevronsUpDownIcon, - GlobeIcon, + Funnel, MinusIcon, - MoreHorizontal, - WaypointsIcon + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -44,7 +65,8 @@ import { useRef, useState, useTransition, - type ComponentRef + type ComponentRef, + type ReactNode } from "react"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; @@ -77,18 +99,27 @@ export type RedirectRow = { baseDomain: string | null; }; +export type RedirectFilterDomain = { + domainId: string; + baseDomain: string; +}; + type RedirectsTableProps = { redirects: RedirectRow[]; orgId: string; pagination: PaginationState; rowCount: number; + initialFilterResource?: SelectedResource | null; + initialFilterDomain?: RedirectFilterDomain | null; }; export default function RedirectsTable({ redirects, orgId, pagination, - rowCount + rowCount, + initialFilterResource = null, + initialFilterDomain = null }: RedirectsTableProps) { const router = useRouter(); const t = useTranslations(); @@ -103,6 +134,48 @@ export default function RedirectsTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + const [domainFilterOpen, setDomainFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); + + const domainIdQ = searchParams.get("domainId"); + const selectedDomain: RedirectFilterDomain | null = useMemo(() => { + if (!domainIdQ) { + return null; + } + if (initialFilterDomain && initialFilterDomain.domainId === domainIdQ) { + return initialFilterDomain; + } + return { + domainId: domainIdQ, + baseDomain: t("redirectFilterDomainFallback", { id: domainIdQ }) + }; + }, [domainIdQ, initialFilterDomain, t]); + + const resourceIdQ = searchParams.get("resourceId"); + const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN; + const selectedResource: SelectedResource | null = useMemo(() => { + if ( + !resourceIdQ || + !Number.isInteger(resourceIdNum) || + resourceIdNum <= 0 + ) { + return null; + } + if ( + initialFilterResource && + initialFilterResource.resourceId === resourceIdNum + ) { + return initialFilterResource; + } + return { + name: t("redirectFilterResourceFallback", { id: resourceIdNum }), + resourceId: resourceIdNum, + fullDomain: null, + niceId: "", + ssl: false, + wildcard: false + }; + }, [initialFilterResource, resourceIdQ, resourceIdNum, t]); function refreshData() { startTransition(() => { @@ -146,6 +219,26 @@ export default function RedirectsTable({ filter({ searchParams }); } + const clearDomainFilter = () => { + handleFilterChange("domainId", undefined); + setDomainFilterOpen(false); + }; + + const clearResourceFilter = () => { + handleFilterChange("resourceId", undefined); + setResourceFilterOpen(false); + }; + + const onPickDomain = (domain: RedirectFilterDomain) => { + handleFilterChange("domainId", domain.domainId); + setDomainFilterOpen(false); + }; + + const onPickResource = (resource: SelectedResource) => { + handleFilterChange("resourceId", String(resource.resourceId)); + setResourceFilterOpen(false); + }; + function toggleSort(column: string) { filter({ searchParams: getNextSortOrder(column, searchParams) }); } @@ -239,53 +332,93 @@ export default function RedirectsTable({ cell: ({ row }) => {row.original.niceId} }, { - id: "attachedTo", - friendlyName: t("redirectAttachedTo"), + id: "domain", + friendlyName: t("domain"), header: () => ( - {t("redirectAttachedTo")} + + + ), cell: ({ row }) => { const redirect = row.original; - if (redirect.resourceId && redirect.resourceNiceId) { - return ( - - ); + if (!redirect.domainId || !redirect.baseDomain) { + return -; } - if (redirect.domainId) { - return ( - - ); + {redirect.baseDomain} + + + + ); + } + }, + { + id: "resource", + friendlyName: t("resource"), + header: () => ( + + + + ), + cell: ({ row }) => { + const redirect = row.original; + + if ( + !redirect.resourceId || + !redirect.resourceName || + !redirect.resourceNiceId + ) { + return -; } - return -; + return ( + + ); } }, @@ -506,7 +639,15 @@ export default function RedirectsTable({ ) } ], - [orgId, t, searchParams] + [ + orgId, + t, + searchParams, + domainFilterOpen, + resourceFilterOpen, + selectedDomain, + selectedResource + ] ); return ( @@ -553,7 +694,6 @@ export default function RedirectsTable({ isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ - attachedTo: false, niceId: false }} enableColumnVisibility @@ -603,3 +743,117 @@ function RedirectEnabledForm({ ); } + +type ColumnEntityFilterProps = { + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + selectedName?: string | null; + clearLabel: string; + onClear: () => void; + children: ReactNode; +}; + +function ColumnEntityFilter({ + label, + open, + onOpenChange, + selectedName, + clearLabel, + onClear, + children +}: ColumnEntityFilterProps) { + return ( + + + + + +
+ +
+ {children} +
+
+ ); +} + +type DomainFilterListProps = { + orgId: string; + selectedDomainId: string | null; + onSelect: (domain: RedirectFilterDomain) => void; +}; + +function DomainFilterList({ + orgId, + selectedDomainId, + onSelect +}: DomainFilterListProps) { + const t = useTranslations(); + const { data: domains = [] } = useQuery(orgQueries.domains({ orgId })); + + return ( + + + + {t("domainsNotFound")} + + {domains.map((domain) => ( + + onSelect({ + domainId: domain.domainId, + baseDomain: domain.baseDomain + }) + } + > + + {domain.baseDomain} + + ))} + + + + ); +} From 22b741179498659aff73b9e99be322e8c70ad607 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 2 Oct 2026 17:11:06 -0400 Subject: [PATCH 42/48] add redirect routes to integration api --- messages/en-US.json | 5 + server/index.ts | 2 + server/middlewares/integration/index.ts | 1 + .../integration/verifyApiKeyRedirectAccess.ts | 116 ++++++++++++++++++ server/routers/integration.ts | 51 ++++++++ src/components/PermissionsSelectBox.tsx | 8 ++ 6 files changed, 183 insertions(+) create mode 100644 server/middlewares/integration/verifyApiKeyRedirectAccess.ts diff --git a/messages/en-US.json b/messages/en-US.json index d1486cbe6..ab8e3f7a6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1512,6 +1512,11 @@ "actionGetVirtualApiKey": "Get Virtual API Key", "actionListVirtualApiKeys": "List Virtual API Keys", "actionUpdateVirtualApiKey": "Update Virtual API Key", + "actionCreateRedirect": "Create Redirect", + "actionDeleteRedirect": "Delete Redirect", + "actionGetRedirect": "Get Redirect", + "actionListRedirects": "List Redirects", + "actionUpdateRedirect": "Update Redirect", "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", diff --git a/server/index.ts b/server/index.ts index 055dae57b..7e3ed3261 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,7 @@ import { RemoteExitNode, Session, SiteResource, + Redirect, User, UserOrg, VirtualApiKey @@ -96,6 +97,7 @@ declare global { aiModel?: AiModel; aiBudget?: AiBudget; virtualApiKey?: VirtualApiKey; + redirect?: Redirect; orgPolicyAllowed?: boolean; } } diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index c516ede09..45ca71f7a 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -22,3 +22,4 @@ export * from "./verifyApiKeyAiBudgetAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess"; export * from "./verifyVirtualApiKeyAccess"; +export * from "./verifyApiKeyRedirectAccess"; diff --git a/server/middlewares/integration/verifyApiKeyRedirectAccess.ts b/server/middlewares/integration/verifyApiKeyRedirectAccess.ts new file mode 100644 index 000000000..373310da6 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyRedirectAccess.ts @@ -0,0 +1,116 @@ +import { Request, Response, NextFunction } from "express"; +import { Redirect, apiKeyOrg, db, redirects } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyRedirectAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const redirectIdRaw = getFirstString(req.params.redirectId); + const niceId = getFirstString(req.params.niceId); + const orgIdParam = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgIdParam) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + let redirect: Redirect | undefined; + + if (niceId) { + const [redirectRes] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.niceId, niceId), + eq(redirects.orgId, orgIdParam) + ) + ) + .limit(1); + redirect = redirectRes; + } else { + const redirectId = Number.parseInt(redirectIdRaw ?? "", 10); + + if (Number.isNaN(redirectId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid redirect ID") + ); + } + + const [redirectRes] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgIdParam) + ) + ) + .limit(1); + redirect = redirectRes; + } + + if (!redirect) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectIdRaw || niceId} not found` + ) + ); + } + + if (apiKey.isRoot) { + req.redirect = redirect; + return next(); + } + + const orgId = redirect.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.redirect = redirect; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying redirect access" + ) + ); + } +} diff --git a/server/routers/integration.ts b/server/routers/integration.ts index bcf5a6a32..bccc1a7d3 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -16,6 +16,7 @@ import * as siteResource from "./siteResource"; import * as aiProvider from "./aiProvider"; import * as aiBudget from "./aiBudget"; import * as virtualApiKey from "./virtualApiKey"; +import * as redirect from "./redirect"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -38,6 +39,7 @@ import { verifyApiKeyAiModelAccess, verifyApiKeyAiBudgetAccess, verifyApiKeyVirtualApiKeyAccess, + verifyApiKeyRedirectAccess, verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; @@ -1833,3 +1835,52 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.listAiBudgets), aiBudget.listAiBudgetsForVirtualApiKey ); + +authenticated.put( + "/org/:orgId/redirect", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createRedirect), + logActionAudit(ActionsEnum.createRedirect), + redirect.createRedirect +); + +authenticated.get( + "/org/:orgId/redirects", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listRedirects), + redirect.listRedirects +); + +authenticated.get( + "/org/:orgId/redirects/:redirectId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); + +authenticated.get( + "/org/:orgId/redirect/:niceId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); + +authenticated.post( + "/org/:orgId/redirects/:redirectId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.updateRedirect), + logActionAudit(ActionsEnum.updateRedirect), + redirect.updateRedirect +); + +authenticated.delete( + "/org/:orgId/redirects/:redirectId", + verifyApiKeyOrgAccess, + verifyApiKeyRedirectAccess, + verifyApiKeyHasAction(ActionsEnum.deleteRedirect), + logActionAudit(ActionsEnum.deleteRedirect), + redirect.deleteRedirect +); diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 64e86c39e..499dc2777 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -49,6 +49,14 @@ function getActionsCategories(root: boolean) { [t("actionRestartOrgDomain")]: "restartOrgDomain" }, + Redirect: { + [t("actionCreateRedirect")]: "createRedirect", + [t("actionDeleteRedirect")]: "deleteRedirect", + [t("actionGetRedirect")]: "getRedirect", + [t("actionListRedirects")]: "listRedirects", + [t("actionUpdateRedirect")]: "updateRedirect" + }, + Site: { [t("actionCreateSite")]: "createSite", [t("actionDeleteSite")]: "deleteSite", From 7386fa523448bbacc64223d3c23331536812b3fb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 15 Oct 2026 11:25:07 -0400 Subject: [PATCH 43/48] ui improvements --- server/routers/redirect/listRedirects.ts | 2 ++ src/app/[orgId]/settings/redirects/page.tsx | 2 ++ src/components/RedirectForm.tsx | 4 +++- src/components/RedirectsTable.tsx | 22 +++++++++++---------- src/hooks/useStoredColumnVisibility.ts | 7 ++++--- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index 518d27298..1f33af71a 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -30,6 +30,7 @@ export type ListRedirectsResponse = PaginatedResponse<{ resourceName: string | null; resourceNiceId: string | null; resourceFullDomain: string | null; + resourceSsl: boolean | null; resourceDomainId: string | null; domainId: string | null; baseDomain: string | null; @@ -236,6 +237,7 @@ export async function listRedirects( resourceName: resources.name, resourceNiceId: resources.niceId, resourceFullDomain: resources.fullDomain, + resourceSsl: resources.ssl, resourceDomainId: resources.domainId, domainId: redirects.domainId, baseDomain: domains.baseDomain diff --git a/src/app/[orgId]/settings/redirects/page.tsx b/src/app/[orgId]/settings/redirects/page.tsx index 7cc04837b..b4bb18f25 100644 --- a/src/app/[orgId]/settings/redirects/page.tsx +++ b/src/app/[orgId]/settings/redirects/page.tsx @@ -115,11 +115,13 @@ export default async function RedirectIndexPage(props: RedirectIndexPageProps) { rewritePathType: redirect.rewritePathType, priority: redirect.priority, permanent: redirect.permanent, + ssl: redirect.ssl, enabled: redirect.enabled, resourceId: redirect.resourceId, resourceName: redirect.resourceName, resourceNiceId: redirect.resourceNiceId, resourceFullDomain: redirect.resourceFullDomain, + resourceSsl: redirect.resourceSsl, resourceDomainId: redirect.resourceDomainId, domainId: redirect.domainId, baseDomain: redirect.baseDomain diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index e8c703e23..c29f86ec2 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -290,6 +290,7 @@ export default function RedirectForm({ title: t("success"), description: t("redirectUpdated") }); + router.refresh(); } else { const res = await api.put< AxiosResponse @@ -298,8 +299,9 @@ export default function RedirectForm({ title: t("success"), description: t("redirectCreated") }); + const niceId = res.data.data.redirect.niceId; + router.push(`/${orgId}/settings/redirects/${niceId}`); } - router.push(`/${orgId}/settings/redirects/`); } catch (e) { toast({ variant: "destructive", diff --git a/src/components/RedirectsTable.tsx b/src/components/RedirectsTable.tsx index 2918c1e09..9519cd150 100644 --- a/src/components/RedirectsTable.tsx +++ b/src/components/RedirectsTable.tsx @@ -89,11 +89,13 @@ export type RedirectRow = { rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; priority: number | null; permanent: boolean; + ssl: boolean; enabled: boolean; resourceId: number | null; resourceName: string | null; resourceNiceId: string | null; resourceFullDomain: string | null; + resourceSsl: boolean | null; resourceDomainId: string | null; domainId: string | null; baseDomain: string | null; @@ -315,14 +317,7 @@ export default function RedirectsTable({ ); }, - cell: ({ row }) => ( - - {row.original.name} - - ) + cell: ({ row }) => {row.original.name} }, { id: "niceId", @@ -453,7 +448,13 @@ export default function RedirectsTable({ ? withPrefixGlob(redirect.matchPath) : redirect.matchPath : ""; - const source = `${host ?? ""}${matchPath}`; + // Resource-attached redirects inherit the resource's ssl. + const sourceSsl = redirect.resourceId + ? (redirect.resourceSsl ?? true) + : redirect.ssl; + const source = host + ? `${sourceSsl ? "https" : "http"}://${host}${matchPath}` + : matchPath; if (!source) { return -; @@ -694,7 +695,8 @@ export default function RedirectsTable({ isRefreshing={isRefreshing || isFiltering} rowCount={rowCount} columnVisibility={{ - niceId: false + niceId: false, + priority: false }} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/hooks/useStoredColumnVisibility.ts b/src/hooks/useStoredColumnVisibility.ts index 1d5d66628..e2a1802f4 100644 --- a/src/hooks/useStoredColumnVisibility.ts +++ b/src/hooks/useStoredColumnVisibility.ts @@ -20,7 +20,9 @@ const getStoredColumnVisibility = ( const parsed = JSON.parse(stored); // Validate that it's an object if (typeof parsed === "object" && parsed !== null) { - return parsed; + // Stored choices win. Keys only present in the defaults + // (for example a newly hidden column) still apply. + return { ...(defaultVisibility || {}), ...parsed }; } } } catch (error) { @@ -60,8 +62,7 @@ export function useStoredColumnVisibility( const setColumnVisibility = useCallback( ( updaterOrValue: - | VisibilityState - | ((old: VisibilityState) => VisibilityState) + VisibilityState | ((old: VisibilityState) => VisibilityState) ) => { if (typeof updaterOrValue === "function") { setVisibility((oldValue) => { From 1f1d3d7e0f6910a08267d3c6ccf78fd807453776 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 15 Oct 2026 12:01:31 -0400 Subject: [PATCH 44/48] fix merge --- server/lib/traefik/loadBalancer.test.ts | 9 --------- server/lib/traefik/loadBalancer.ts | 4 ++-- server/lib/traefik/types.ts | 2 ++ 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/server/lib/traefik/loadBalancer.test.ts b/server/lib/traefik/loadBalancer.test.ts index 2adb5f040..011f6a000 100644 --- a/server/lib/traefik/loadBalancer.test.ts +++ b/server/lib/traefik/loadBalancer.test.ts @@ -11,21 +11,12 @@ function makeTarget(ip: string, siteType: string): TargetWithSite { return { targetId: 1, resourceId: 1, - providerId: null, - siteId: 1, ip, port: 8080, internalPort: 9000, method: "http", enabled: true, health: "healthy", - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: 100, - mode: "http", - authToken: null, site: { siteId: 1, type: siteType, diff --git a/server/lib/traefik/loadBalancer.ts b/server/lib/traefik/loadBalancer.ts index 365756b37..8af343989 100644 --- a/server/lib/traefik/loadBalancer.ts +++ b/server/lib/traefik/loadBalancer.ts @@ -49,7 +49,7 @@ export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) { target.site.type === "wireguard" ) { return { - url: `${target.method}://${formatEndpoint(target.ip, target.port)}` + url: `${target.method}://${formatEndpoint(target.ip!, target.port!)}` }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; @@ -124,7 +124,7 @@ export function buildTcpUdpLoadBalancerServers(targets: TargetWithSite[]) { target.site.type === "wireguard" ) { return { - address: formatEndpoint(target.ip, target.port) + address: formatEndpoint(target.ip!, target.port!) }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; diff --git a/server/lib/traefik/types.ts b/server/lib/traefik/types.ts index e6a552d9c..cec77aad6 100644 --- a/server/lib/traefik/types.ts +++ b/server/lib/traefik/types.ts @@ -33,6 +33,8 @@ export type ResourceWithTargets = Pick< | "domainId" | "enabled" | "stickySession" + | "loadBalancingStrategy" + | "mtlsEnabled" | "tlsServerName" | "setHostHeader" | "enableProxy" From 5e8673a206c0bd293c0fadec70ffb50fc665120e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 15 Oct 2026 14:20:59 -0400 Subject: [PATCH 45/48] reorganize form --- messages/en-US.json | 6 +- src/components/RedirectForm.tsx | 514 ++++++++++++++++---------------- 2 files changed, 265 insertions(+), 255 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 207485ea9..a9fb43e5f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4536,12 +4536,14 @@ "selectedRedirectDomain": "Selected Domain", "selectedRedirectResource": "Selected Resource", "redirectResourceNoDomain": "This resource has no domain", - "redirectSourceSectionDescription": "Choose the domain or resource this redirect applies to", + "redirectSourceSectionDescription": "Choose the domain or resource, and which incoming paths, this redirect matches", + "redirectDestinationSectionDescription": "Where matching requests are sent, and how the path changes", "redirectSettingsDescription": "Set which paths to match and where to send them", "redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it", - "redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource", "redirectAttachDomain": "Domain", + "redirectAttachDomainDescription": "Apply this redirect to a whole domain", "redirectAttachResource": "Resource", + "redirectAttachResourceDescription": "Apply this redirect to a single resource", "redirectDomainRequired": "Select a domain to attach this redirect to", "redirectResourceRequired": "Select a resource to attach this redirect to", "redirectPermanent": "Permanent Redirect", diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index c29f86ec2..02f1007b0 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -58,7 +58,7 @@ import { isValidRegex } from "@server/routers/redirect/validation"; import type { AxiosResponse } from "axios"; -import { InfoIcon, Plus } from "lucide-react"; +import { ChevronDown, ChevronUp, InfoIcon, Plus } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -111,6 +111,7 @@ export default function RedirectForm({ .join(".") : null ); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const formSchema = useMemo( () => @@ -320,35 +321,44 @@ export default function RedirectForm({ return ( - - - - {t("redirectSource")} - - - {t("redirectSourceSectionDescription")} - - +
+ + + + + {t("general")} + + - - - - + + - - {resourceMissingDomain && ( - - - - {t( - "redirectDomainSelectedDomainNotFound" - )} - - - )} + + ( + + + {t("name")} + + + + + + + )} + /> + + + + + + + - - ( - - - {t("name")} - - - - - - - )} - /> - + + + + {t("redirectSource")} + + + {t("redirectSourceSectionDescription")} + + + + + + + {resourceMissingDomain && ( + + + + + {t( + "redirectDomainSelectedDomainNotFound" + )} + + + + )} - + {t( "redirectAttachDomain" )} - + {t( "redirectAttachResource" )} - - {t( - "redirectAttachedToDescription" - )} - )} @@ -552,150 +576,7 @@ export default function RedirectForm({ )} - {attachTo === "resource" && ( - - - - {t("resourceDomain")} - - - - - )} - - {/* The cloud only serves HTTPS, so there is nothing to toggle there. */} - {build !== "saas" && ( - - ( - - - - - - - )} - /> - - )} - - - - - -
- - - - - {t("redirectSettings")} - - - {t("redirectSettingsDescription")} - - - - - -
- - - - - - - ( - - - {t( - "redirectDestinationHost" - )} - - - - - - {t( - "redirectDestinationHostDescription" - )} - - - - )} - /> - - - - + + + + {showAdvancedSettings && ( + <> + {/* The cloud only serves HTTPS, and resource-attached redirects inherit TLS, so there is nothing to toggle in those cases. */} + {build !== "saas" && + attachTo === "domain" && ( + + ( + + + + + + + )} + /> + + )} + + ( + + + {t("priority")} + + + + field.onChange( + e + .target + .valueAsNumber + ) + } + /> + + + {t( + "redirectPriorityDescription" + )} + + + + )} + /> + + + )} + + + + + + + + + {t("redirectDestination")} + + + {t("redirectDestinationSectionDescription")} + + + + + + + + + + + + ( + + + {t( + "redirectDestinationHost" + )} + + + + + + {t( + "redirectDestinationHostDescription" + )} + + + + )} + /> + + + - - ( - - - {t("priority")} - - - - field.onChange( - e.target - .valueAsNumber - ) - } - /> - - - {t( - "redirectPriorityDescription" - )} - - - - )} - /> - - - - - - -
+
+
+
-
- - -
+
+ + +
+ +
); } From 857bc9528ff70326a98eeaccd27a8db0e1ac6ff5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 15 Oct 2026 14:39:29 -0400 Subject: [PATCH 46/48] more ui adjustments --- messages/en-US.json | 21 ++++++++++--------- .../resources/private/[niceId]/http/page.tsx | 2 +- .../resources/private/[niceId]/layout.tsx | 5 ++++- .../resources/private/create/page.tsx | 4 ++-- .../resources/public/[niceId]/layout.tsx | 5 ++++- .../settings/resources/public/create/page.tsx | 2 +- src/components/PrivateResourceInfoBox.tsx | 2 +- src/components/RedirectForm.tsx | 4 +++- 8 files changed, 27 insertions(+), 18 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a9fb43e5f..f931ea776 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -295,7 +295,8 @@ "resourceTypeDescription": "This controls the resource protocol and can’t be changed later.", "resourceTypeSearch": "Search resource types...", "resourceTypeNotFound": "No resource type found", - "resourceTypeHttpDescription": "Proxy web traffic over HTTPS using a domain name", + "resourceTypeHttp": "HTTP(S)", + "resourceTypeHttpDescription": "Proxy web traffic over HTTP or HTTPS using a domain name", "resourceTypeInferenceDescription": "Route AI requests through attached providers", "resourceTypeSshDescription": "Access an SSH server from the browser", "resourceTypeRdpDescription": "Access a remote desktop from the browser", @@ -4493,7 +4494,7 @@ "actionUpdateSiteApprovals": "Update Site Approvals", "check": "Check", "redirectsTitle": "Manage Redirects", - "redirectsDescription": "Forward requests from a path on your domains or resources to another URL", + "redirectsDescription": "Forward requests from a path on your domains or resources to another URL.", "redirectsSearch": "Search redirects...", "redirectAdd": "Add Redirect", "redirectSource": "Source", @@ -4518,7 +4519,7 @@ "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", + "redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path.", "redirectDestinationHostInvalid": "Enter a valid destination with a scheme, such as https://example.com", "redirectMatchPathDescription": "Which incoming paths this redirect applies to", "redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.", @@ -4527,8 +4528,8 @@ "redirectPriorityInvalid": "Enter a whole number between 1 and 1000", "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", + "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", @@ -4536,14 +4537,14 @@ "selectedRedirectDomain": "Selected Domain", "selectedRedirectResource": "Selected Resource", "redirectResourceNoDomain": "This resource has no domain", - "redirectSourceSectionDescription": "Choose the domain or resource, and which incoming paths, this redirect matches", + "redirectSourceSectionDescription": "Choose the domain or resource, and which incoming paths, this redirect matches.", "redirectDestinationSectionDescription": "Where matching requests are sent, and how the path changes", - "redirectSettingsDescription": "Set which paths to match and where to send them", - "redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it", + "redirectSettingsDescription": "Set which paths to match and where to send them.", + "redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it.", "redirectAttachDomain": "Domain", - "redirectAttachDomainDescription": "Apply this redirect to a whole domain", + "redirectAttachDomainDescription": "Apply this redirect to a whole domain.", "redirectAttachResource": "Resource", - "redirectAttachResourceDescription": "Apply this redirect to a single resource", + "redirectAttachResourceDescription": "Apply this redirect to a single resource.", "redirectDomainRequired": "Select a domain to attach this redirect to", "redirectResourceRequired": "Select a resource to attach this redirect to", "redirectPermanent": "Permanent Redirect", diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx index c764b0446..59875f31a 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -80,7 +80,7 @@ export default function PrivateResourceHttpPage() { - {t("httpSettings")} + {t("resourceTypeHttp")} {t( diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index 31fe7cfae..554f794c2 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -62,7 +62,10 @@ export default async function PrivateResourceLayout( href: `/{orgId}/settings/resources/private/{niceId}/general` }, { - title: t(modeSettingsKey), + title: + siteResource.mode === "http" + ? t("resourceTypeHttp") + : t(modeSettingsKey), href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode === "inference" ? "ai-gateway" : siteResource.mode}` }, { diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 790de01cf..3427e99a1 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -151,7 +151,7 @@ export default function CreatePrivateResourcePage() { }, { value: "http" as const, - title: t("createInternalResourceDialogModeHttp"), + title: t("resourceTypeHttp"), description: t("privateResourceTypeHttpDescription") }, { @@ -602,7 +602,7 @@ export default function CreatePrivateResourcePage() { - {t("httpSettings")} + {t("resourceTypeHttp")} {t( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 2bee1be7b..511fa6ac4 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -88,7 +88,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/public/{niceId}/general` }, { - title: t(`${resource.mode}Settings`), + title: + resource.mode === "http" + ? t("resourceTypeHttp") + : t(`${resource.mode}Settings`), href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode === "inference" ? "ai-gateway" : resource.mode}` } ]; diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index f05052001..598b691ad 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -770,7 +770,7 @@ export default function Page() { { title: string; description: string } > = { http: { - title: t("createInternalResourceDialogModeHttp"), + title: t("resourceTypeHttp"), description: t("resourceTypeHttpDescription") }, inference: { diff --git a/src/components/PrivateResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx index bec193bae..8db5ac9f8 100644 --- a/src/components/PrivateResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -91,7 +91,7 @@ export function PrivateResourceInfoSections({ const modeLabel: Record = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), - http: t("editInternalResourceDialogModeHttp"), + http: t("resourceTypeHttp"), ssh: t("editInternalResourceDialogModeSsh"), inference: t("editInternalResourceDialogModeInference"), gateway: t("editInternalResourceDialogModeGateway") diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index 02f1007b0..9f14d61f2 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -483,7 +483,9 @@ export default function RedirectForm({ defaultDomainId={ redirect?.domainId } - allowWildcard + allowWildcard={ + false + } defaultSubdomain={ redirect?.subdomain } From a6a3816b56ae9e3b4ea5d1e8d55862a52ccd8ad9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 15 Oct 2026 15:42:43 -0400 Subject: [PATCH 47/48] more ui adjustments --- messages/en-US.json | 2 +- server/lib/traefik/redirect.ts | 4 - src/components/RedirectForm.tsx | 904 ++++++++++++++++---------------- 3 files changed, 455 insertions(+), 455 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f931ea776..58552e197 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4526,7 +4526,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. Redirects are always evaluated before targets.", + "redirectPriorityDescription": "Higher priority routes are evaluated first.", "redirectCreate": "Create Redirect", "redirectCreateDescription": "Forward requests matching a path to another URL.", "redirectEditDescription": "Update how this redirect forwards incoming requests.", diff --git a/server/lib/traefik/redirect.ts b/server/lib/traefik/redirect.ts index 787b95509..05f6eae00 100644 --- a/server/lib/traefik/redirect.ts +++ b/server/lib/traefik/redirect.ts @@ -57,10 +57,6 @@ export function buildRedirectConfig(params: { for (const redirect of redirects) { const routerName = `${redirect.redirectId}-redirect-${redirect.name}-router`; - logger.debug( - `Processing redirect ${redirect.name} with domain ${redirect.fullDomain}` - ); - if (!redirect.enabled) { logger.debug( `Redirect ${redirect.name} is disabled, skipping Traefik config` diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index 9f14d61f2..56846d3bc 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -15,6 +15,7 @@ import { SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, + SettingsSectionGrid, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; @@ -337,7 +338,7 @@ export default function RedirectForm({ - + - - - - - - - - - {t("redirectSource")} - - - {t("redirectSourceSectionDescription")} - - - - - - - {resourceMissingDomain && ( - - - - - {t( - "redirectDomainSelectedDomainNotFound" - )} - - - - )} - - - ( - - - {t( - "redirectAttachedTo" - )} - - - - - )} - /> - - {attachTo === "domain" ? ( - - ( - - { - form.setValue( - "domainId", - res?.domainId ?? - null, - { - shouldValidate: true - } - ); - form.setValue( - "subdomain", - res?.subdomain || - null - ); - setDomainFullDomain( - res?.fullDomain ?? - null - ); - }} - /> - - - )} - /> - - ) : ( - - ( - - - {t( - "selectedRedirectResource" - )} - - - - - - - - - { - setSelectedResource( - resource - ); - field.onChange( - resource.resourceId - ); - }} - /> - - - - - )} - /> - - )} - - - ( - - - {t("matchPath")} - - { - // No match path - // means the - // redirect applies - // to every path; - // pathMatchType is - // NOT NULL so it - // keeps a default. - field.onChange( - config.path || - null - ); - form.setValue( - "pathMatchType", - (config.pathMatchType as - | "exact" - | "prefix" - | "regex") || - DEFAULT_PATH_MATCH_TYPE - ); - }} - trigger={ - - } - /> - - {t( - "redirectMatchPathDescription" - )} - - - - )} - /> - + + + + { + setSelectedResource( + resource + ); + field.onChange( + resource.resourceId + ); + }} + /> + + + + + )} + /> + + )} - - ( - - - {t("rewritePath")} - - { - field.onChange( - config.rewritePath || - null - ); - form.setValue( - "rewritePathType", - (config.rewritePathType as - | "exact" - | "prefix" - | "regex" - | "stripPrefix" - | null) ?? - null - ); - }} - trigger={ - hasRewrite ? ( + {/* The cloud only serves HTTPS, and resource-attached redirects inherit TLS, so there is nothing to toggle in those cases. */} + {build !== "saas" && + attachTo === "domain" && ( + + ( + + + + + + + )} + /> + + )} + + + ( + + + {t("matchPath")} + + { + // No match path + // means the + // redirect applies + // to every path; + // pathMatchType is + // NOT NULL so it + // keeps a default. + field.onChange( + config.path || + null + ); + form.setValue( + "pathMatchType", + (config.pathMatchType as + | "exact" + | "prefix" + | "regex") || + DEFAULT_PATH_MATCH_TYPE + ); + }} + trigger={ - ) : ( - - ) - } - /> - - {t( - "redirectRewritePathDescription" - )} - - - - )} - /> - - - - ( - - - - - - - )} - /> - - - - - + + {t( + "redirectMatchPathDescription" + )} + + + + )} + /> + + + + + + + + + + {t("redirectDestination")} + + + {t("redirectDestinationSectionDescription")} + + + + + + + + + + + + ( + + + {t( + "redirectDestinationHost" + )} + + + + + + {t( + "redirectDestinationHostDescription" + )} + + + + )} + /> + + + + ( + + + {t("rewritePath")} + + { + field.onChange( + config.rewritePath || + null + ); + form.setValue( + "rewritePathType", + (config.rewritePathType as + | "exact" + | "prefix" + | "regex" + | "stripPrefix" + | null) ?? + null + ); + }} + trigger={ + hasRewrite ? ( + + ) : ( + + ) + } + /> + + {t( + "redirectRewritePathDescription" + )} + + + + )} + /> + + + + + +