diff --git a/messages/en-US.json b/messages/en-US.json index d9a60837..f4e063ce 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1522,5 +1522,7 @@ "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Edit file: docker-compose.yml", "emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", - "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here." -} \ No newline at end of file + "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", + "rewritePath": "Rewrite Path", + "rewritePathDescription": "Optionally rewrite the path before forwarding to the target." +} diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 94e9d98a..18b29f35 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -123,7 +123,9 @@ export const targets = pgTable("targets", { internalPort: integer("internalPort"), enabled: boolean("enabled").notNull().default(true), path: text("path"), - pathMatchType: text("pathMatchType") // exact, prefix, regex + pathMatchType: text("pathMatchType"), // exact, prefix, regex + rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target + rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix }); export const exitNodes = pgTable("exitNodes", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 0fb65d03..e38d6b67 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -135,7 +135,9 @@ export const targets = sqliteTable("targets", { internalPort: integer("internalPort"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), path: text("path"), - pathMatchType: text("pathMatchType") // exact, prefix, regex + pathMatchType: text("pathMatchType"), // exact, prefix, regex + rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target + rewritePathType: text("rewritePathType") // exact, prefix, regex, stripPrefix }); export const exitNodes = sqliteTable("exitNodes", { diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index c6ab6f40..9b349281 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -107,7 +107,9 @@ export async function updateProxyResources( enabled: targetData.enabled, internalPort: internalPortToCreate, path: targetData.path, - pathMatchType: targetData["path-match"] + pathMatchType: targetData["path-match"], + rewritePath: targetData.rewritePath, + rewritePathType: targetData["rewrite-match"] }) .returning(); @@ -327,7 +329,9 @@ export async function updateProxyResources( port: targetData.port, enabled: targetData.enabled, path: targetData.path, - pathMatchType: targetData["path-match"] + pathMatchType: targetData["path-match"], + rewritePath: targetData.rewritePath, + rewritePathType: targetData["rewrite-match"] }) .where(eq(targets.targetId, existingTarget.targetId)) .returning(); diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 9b3a7a20..dda672a6 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -14,7 +14,9 @@ export const TargetSchema = z.object({ enabled: z.boolean().optional().default(true), "internal-port": z.number().int().min(1).max(65535).optional(), path: z.string().optional(), - "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable() + "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional(), + "rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }); export type TargetData = z.infer; @@ -183,7 +185,7 @@ export const ClientResourceSchema = z.object({ "proxy-port": z.number().min(1).max(65535), "hostname": z.string().min(1).max(255), "internal-port": z.number().min(1).max(65535), - enabled: z.boolean().optional().default(true) + enabled: z.boolean().optional().default(true) }); // Schema for the entire configuration object diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index fb85f566..dd85c888 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -32,7 +32,9 @@ const createTargetSchema = z port: z.number().int().min(1).max(65535), enabled: z.boolean().default(true), path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }) .strict(); diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index ca1159d2..4a1d99a0 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -46,7 +46,9 @@ function queryTargets(resourceId: number) { siteId: targets.siteId, siteType: sites.type, path: targets.path, - pathMatchType: targets.pathMatchType + pathMatchType: targets.pathMatchType, + rewritePath: targets.rewritePath, + rewritePathType: targets.rewritePathType }) .from(targets) .leftJoin(sites, eq(sites.siteId, targets.siteId)) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 928a1a55..1e47ce96 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -28,7 +28,9 @@ const updateTargetBodySchema = z port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional(), path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 4bbb13d3..fa722ed5 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -90,6 +90,204 @@ export async function traefikConfigProvider( } } + +function validatePathRewriteConfig( + path: string | null, + pathMatchType: string | null, + rewritePath: string | null, + rewritePathType: string | null +): { isValid: boolean; error?: string } { + // If no path matching is configured, no rewriting is possible + if (!path || !pathMatchType) { + if (rewritePath || rewritePathType) { + return { + isValid: false, + error: "Path rewriting requires path matching to be configured" + }; + } + return { isValid: true }; + } + + if (rewritePathType !== "stripPrefix") { + if ((rewritePath && !rewritePathType) || (!rewritePath && rewritePathType)) { + return { isValid: false, error: "Both rewritePath and rewritePathType must be specified together" }; + } + } + + + if (!rewritePath || !rewritePathType) { + return { isValid: true }; + } + + const validPathMatchTypes = ["exact", "prefix", "regex"]; + if (!validPathMatchTypes.includes(pathMatchType)) { + return { + isValid: false, + error: `Invalid pathMatchType: ${pathMatchType}. Must be one of: ${validPathMatchTypes.join(", ")}` + }; + } + + const validRewritePathTypes = ["exact", "prefix", "regex", "stripPrefix"]; + if (!validRewritePathTypes.includes(rewritePathType)) { + return { + isValid: false, + error: `Invalid rewritePathType: ${rewritePathType}. Must be one of: ${validRewritePathTypes.join(", ")}` + }; + } + + if (pathMatchType === "regex") { + try { + new RegExp(path); + } catch (e) { + return { + isValid: false, + error: `Invalid regex pattern in path: ${path}` + }; + } + } + + + // Additional validation for stripPrefix + if (rewritePathType === "stripPrefix") { + if (pathMatchType !== "prefix") { + logger.warn(`stripPrefix rewrite type is most effective with prefix path matching. Current match type: ${pathMatchType}`); + } + } + + return { isValid: true }; +} + + +function createPathRewriteMiddleware( + middlewareName: string, + path: string, + pathMatchType: string, + rewritePath: string, + rewritePathType: string +): { middlewares: { [key: string]: any }; chain?: string[] } { + const middlewares: { [key: string]: any } = {}; + + if (pathMatchType !== "regex" && !path.startsWith("/")) { + path = `/${path}`; + } + + if (rewritePathType !== "regex" && rewritePath !== "" && !rewritePath.startsWith("/")) { + rewritePath = `/${rewritePath}`; + } + + switch (rewritePathType) { + case "exact": + // Replace the path with the exact rewrite path + let exactPattern = `^${escapeRegex(path)}$`; + middlewares[middlewareName] = { + replacePathRegex: { + regex: exactPattern, + replacement: rewritePath + } + }; + break; + + case "prefix": + // Replace matched prefix with new prefix, preserve the rest + switch (pathMatchType) { + case "prefix": + middlewares[middlewareName] = { + replacePathRegex: { + regex: `^${escapeRegex(path)}(.*)`, + replacement: `${rewritePath}$1` + } + }; + break; + case "exact": + middlewares[middlewareName] = { + replacePathRegex: { + regex: `^${escapeRegex(path)}$`, + replacement: rewritePath + } + }; + break; + case "regex": + // For regex path matching with prefix rewrite, we assume the regex has capture groups + middlewares[middlewareName] = { + replacePathRegex: { + regex: path, + replacement: rewritePath + } + }; + break; + } + break; + + case "regex": + // Use advanced regex replacement - works with any match type + let regexPattern: string; + if (pathMatchType === "regex") { + regexPattern = path; + } else if (pathMatchType === "prefix") { + regexPattern = `^${escapeRegex(path)}(.*)`; + } else { // exact + regexPattern = `^${escapeRegex(path)}$`; + } + + middlewares[middlewareName] = { + replacePathRegex: { + regex: regexPattern, + replacement: rewritePath + } + }; + break; + + case "stripPrefix": + // Strip the matched prefix and optionally add new path + if (pathMatchType === "prefix") { + middlewares[middlewareName] = { + stripPrefix: { + prefixes: [path] + } + }; + + // If rewritePath is provided and not empty, add it as a prefix after stripping + if (rewritePath && rewritePath !== "" && rewritePath !== "/") { + const addPrefixMiddlewareName = `addprefix-${middlewareName.replace('rewrite-', '')}`; + middlewares[addPrefixMiddlewareName] = { + addPrefix: { + prefix: rewritePath + } + }; + return { + middlewares, + chain: [middlewareName, addPrefixMiddlewareName] + }; + } + } else { + // For exact and regex matches, use replacePathRegex to strip + let regexPattern: string; + if (pathMatchType === "exact") { + regexPattern = `^${escapeRegex(path)}$`; + } else if (pathMatchType === "regex") { + regexPattern = path; + } else { + regexPattern = `^${escapeRegex(path)}`; + } + + const replacement = rewritePath || "/"; + middlewares[middlewareName] = { + replacePathRegex: { + regex: regexPattern, + replacement: replacement + } + }; + } + break; + + default: + logger.error(`Unknown rewritePathType: ${rewritePathType}`); + throw new Error(`Unknown rewritePathType: ${rewritePathType}`); + } + + return { middlewares }; +} + export async function getTraefikConfig( exitNodeId: number, siteTypes: string[] @@ -133,7 +331,8 @@ export async function getTraefikConfig( internalPort: targets.internalPort, path: targets.path, pathMatchType: targets.pathMatchType, - + rewritePath: targets.rewritePath, + rewritePathType: targets.rewritePathType, // Site fields siteId: sites.siteId, siteType: sites.type, @@ -163,12 +362,28 @@ export async function getTraefikConfig( const resourceId = row.resourceId; const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths const pathMatchType = row.pathMatchType || ""; + const rewritePath = row.rewritePath || ""; + const rewritePathType = row.rewritePathType || ""; - // Create a unique key combining resourceId and path+pathMatchType - const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-"); + // Create a unique key combining resourceId, path config, and rewrite config + const pathKey = [targetPath, pathMatchType, rewritePath, rewritePathType] + .filter(Boolean) + .join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); if (!resourcesMap.has(mapKey)) { + const validation = validatePathRewriteConfig( + row.path, + row.pathMatchType, + row.rewritePath, + row.rewritePathType + ); + + if (!validation.isValid) { + logger.error(`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`); + return; + } + resourcesMap.set(mapKey, { resourceId: row.resourceId, fullDomain: row.fullDomain, @@ -186,7 +401,9 @@ export async function getTraefikConfig( targets: [], headers: row.headers, path: row.path, // the targets will all have the same path - pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType + pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType + rewritePath: row.rewritePath, + rewritePathType: row.rewritePathType }); } @@ -199,6 +416,8 @@ export async function getTraefikConfig( port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, + rewritePath: row.rewritePath, + rewritePathType: row.rewritePathType, site: { siteId: row.siteId, type: row.siteType, @@ -230,30 +449,27 @@ export async function getTraefikConfig( for (const [key, resource] of resourcesMap.entries()) { const targets = resource.targets; - const routerName = `${key}-router`; - const serviceName = `${key}-service`; + const sanatizedKey = sanitizeForMiddlewareName(key); + + const routerName = `${sanatizedKey}-router`; + const serviceName = `${sanatizedKey}-service`; const fullDomain = `${resource.fullDomain}`; - const transportName = `${key}-transport`; - const headersMiddlewareName = `${key}-headers-middleware`; + const transportName = `${sanatizedKey}-transport`; + const headersMiddlewareName = `${sanatizedKey}-headers-middleware`; if (!resource.enabled) { continue; } if (resource.http) { - if (!resource.domainId) { + if (!resource.domainId || !resource.fullDomain) { continue; } - if (!resource.fullDomain) { - continue; - } - - // add routers and services empty objects if they don't exist + // Initialize routers and services if they don't exist if (!config_output.http.routers) { config_output.http.routers = {}; } - if (!config_output.http.services) { config_output.http.services = {}; } @@ -288,12 +504,12 @@ export async function getTraefikConfig( certResolver: certResolver, ...(preferWildcardCert ? { - domains: [ - { - main: wildCard - } - ] - } + domains: [ + { + main: wildCard + } + ] + } : {}) }; } @@ -306,9 +522,51 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; + // Handle path rewriting middleware + if (resource.rewritePath && + resource.path && + resource.pathMatchType && + resource.rewritePathType) { + + // Create a unique middleware name + const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${sanitizeForMiddlewareName(key)}`; + + try { + const rewriteResult = createPathRewriteMiddleware( + rewriteMiddlewareName, + resource.path, + resource.pathMatchType, + resource.rewritePath, + resource.rewritePathType + ); + + // Initialize middlewares object if it doesn't exist + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + // the middleware to the config + Object.assign(config_output.http.middlewares, rewriteResult.middlewares); + + // middlewares to the router middleware chain + if (rewriteResult.chain) { + // For chained middlewares (like stripPrefix + addPrefix) + routerMiddlewares.push(...rewriteResult.chain); + } else { + // Single middleware + routerMiddlewares.push(rewriteMiddlewareName); + } + + logger.debug(`Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`); + } catch (error) { + logger.error(`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`); + } + } + + // Handle custom headers middleware if (resource.headers || resource.setHostHeader) { - // if there are headers, parse them into an object const headersObj: { [key: string]: string } = {}; + if (resource.headers) { let headersArr: { name: string; value: string }[] = []; try { @@ -317,9 +575,7 @@ export async function getTraefikConfig( value: string; }[]; } catch (e) { - logger.warn( - `Failed to parse headers for resource ${resource.resourceId}: ${e}` - ); + logger.warn(`Failed to parse headers for resource ${resource.resourceId}: ${e}`); } headersArr.forEach((header) => { @@ -331,9 +587,7 @@ export async function getTraefikConfig( headersObj["Host"] = resource.setHostHeader; } - // check if the object is not empty if (Object.keys(headersObj).length > 0) { - // Add the headers middleware if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } @@ -347,8 +601,10 @@ export async function getTraefikConfig( } } + // Build routing rules let rule = `Host(\`${fullDomain}\`)`; let priority = 100; + if (resource.path && resource.pathMatchType) { priority += 1; // add path to rule based on match type @@ -465,14 +721,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -494,7 +750,7 @@ export async function getTraefikConfig( } } else { // Non-HTTP (TCP/UDP) configuration - if (!resource.enableProxy) { + if (!resource.enableProxy || !resource.proxyPort) { continue; } @@ -573,13 +829,13 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; @@ -590,10 +846,25 @@ export async function getTraefikConfig( function sanitizePath(path: string | null | undefined): string | undefined { if (!path) return undefined; - // clean any non alphanumeric characters from the path and replace with dashes - // the path cant be too long either, so limit to 50 characters - if (path.length > 50) { - path = path.substring(0, 50); + + const trimmed = path.trim(); + if (!trimmed) return undefined; + + // Preserve path structure for rewriting, only warn if very long + if (trimmed.length > 1000) { + logger.warn(`Path exceeds 1000 characters: ${trimmed.substring(0, 100)}...`); + return trimmed.substring(0, 1000); } - return path.replace(/[^a-zA-Z0-9]/g, ""); + + return trimmed; } + +function sanitizeForMiddlewareName(str: string): string { + // Replace any characters that aren't alphanumeric or dash with dash + // and remove consecutive dashes + return str.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); +} + +function escapeRegex(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 1232e542..b37cfba9 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -73,6 +73,7 @@ import { CircleCheck, CircleX, ArrowRight, + Plus, MoveRight } from "lucide-react"; import { ContainersSelector } from "@app/components/ContainersSelector"; @@ -95,9 +96,9 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { Badge } from "@app/components/ui/badge"; import { parseHostTarget } from "@app/lib/parseHostTarget"; import { HeadersInput } from "@app/components/HeadersInput"; +import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), @@ -105,7 +106,9 @@ const addTargetSchema = z.object({ port: z.coerce.number().int().positive(), siteId: z.number().int().positive(), path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }).refine( (data) => { // If path is provided, pathMatchType must be provided @@ -138,7 +141,23 @@ const addTargetSchema = z.object({ { message: "Invalid path configuration" } -); +) + .refine( + (data) => { + // If rewritePath is provided, rewritePathType must be provided + if (data.rewritePath && !data.rewritePathType) { + return false; + } + // If rewritePathType is provided, rewritePath must be provided + if (data.rewritePathType && !data.rewritePath) { + return false; + } + return true; + }, + { + message: "Invalid rewrite path configuration" + } + ); const targetsSettingsSchema = z.object({ stickySession: z.boolean() @@ -259,8 +278,10 @@ export default function ReverseProxyTargets(props: { method: resource.http ? "http" : null, port: "" as any as number, path: null, - pathMatchType: null - } + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + } as z.infer }); const watchedIp = addTargetForm.watch("ip"); @@ -436,6 +457,8 @@ export default function ReverseProxyTargets(props: { ...data, path: data.path || null, pathMatchType: data.pathMatchType || null, + rewritePath: data.rewritePath || null, + rewritePathType: data.rewritePathType || null, siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), @@ -449,7 +472,9 @@ export default function ReverseProxyTargets(props: { method: resource.http ? "http" : null, port: "" as any as number, path: null, - pathMatchType: null + pathMatchType: null, + rewritePath: null, + rewritePathType: null, }); } @@ -469,11 +494,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site?.type || null - } + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); @@ -494,7 +519,9 @@ export default function ReverseProxyTargets(props: { enabled: target.enabled, siteId: target.siteId, path: target.path, - pathMatchType: target.pathMatchType + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType }; if (target.new) { @@ -571,93 +598,66 @@ export default function ReverseProxyTargets(props: { accessorKey: "path", header: t("matchPath"), cell: ({ row }) => { - const [showPathInput, setShowPathInput] = useState( - !!(row.original.path || row.original.pathMatchType) - ); + const hasPathMatch = !!(row.original.path || row.original.pathMatchType); - if (!showPathInput) { - return ( - - ); - } - - return ( -
- - { - const value = e.target.value.trim(); - if (!value) { - setShowPathInput(false); - updateTarget(row.original.targetId, { - ...row.original, - path: null, - pathMatchType: null - }); - } else { - updateTarget(row.original.targetId, { - ...row.original, - path: value - }); - } - }} /> - + {/* */}
+ ) : ( + updateTarget(row.original.targetId, config)} + trigger={ + + } + /> ); - } + }, }, { accessorKey: "siteId", @@ -693,7 +693,7 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > {row.original.siteId @@ -772,31 +772,31 @@ export default function ReverseProxyTargets(props: { }, ...(resource.http ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -856,6 +856,72 @@ export default function ReverseProxyTargets(props: { /> ) }, + { + accessorKey: "rewritePath", + header: t("rewritePath"), + cell: ({ row }) => { + const hasRewritePath = !!(row.original.rewritePath || row.original.rewritePathType); + const noPathMatch = !row.original.path && !row.original.pathMatchType; + + return hasRewritePath && !noPathMatch ? ( +
+ {/* */} + updateTarget(row.original.targetId, config)} + trigger={ + + } + /> + +
+ ) : ( + updateTarget(row.original.targetId, config)} + trigger={ + + } + disabled={noPathMatch} + /> + ); + }, + }, + // { // accessorKey: "protocol", // header: t('targetProtocol'), @@ -968,21 +1034,21 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -1048,34 +1114,34 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" + "newt" ? (() => { - const dockerState = - getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })() + const dockerState = + getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })() : null; })()} @@ -1303,12 +1369,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1527,7 +1593,6 @@ export default function ReverseProxyTargets(props: { } function isIPInSubnet(subnet: string, ip: string): boolean { - // Split subnet into IP and mask parts const [subnetIP, maskBits] = subnet.split("/"); const mask = parseInt(maskBits); diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index f551e418..bb19cc79 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -58,7 +58,7 @@ import { } from "@app/components/ui/popover"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; -import { ArrowRight, MoveRight, SquareArrowOutUpRight } from "lucide-react"; +import { ArrowRight, MoveRight, Plus, SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -92,6 +92,7 @@ import { parseHostTarget } from "@app/lib/parseHostTarget"; import { toASCII, toUnicode } from 'punycode'; import { DomainRow } from "../../../../../components/DomainsTable"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; const baseResourceFormSchema = z.object({ @@ -116,7 +117,9 @@ const addTargetSchema = z.object({ port: z.coerce.number().int().positive(), siteId: z.number().int().positive(), path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable() }).refine( (data) => { // If path is provided, pathMatchType must be provided @@ -149,7 +152,23 @@ const addTargetSchema = z.object({ { message: "Invalid path configuration" } -); +) + .refine( + (data) => { + // If rewritePath is provided, rewritePathType must be provided + if (data.rewritePath && !data.rewritePathType) { + return false; + } + // If rewritePathType is provided, rewritePath must be provided + if (data.rewritePathType && !data.rewritePath) { + return false; + } + return true; + }, + { + message: "Invalid rewrite path configuration" + } + ); type BaseResourceFormValues = z.infer; type HttpResourceFormValues = z.infer; @@ -240,8 +259,10 @@ export default function Page() { method: baseForm.watch("http") ? "http" : null, port: "" as any as number, path: null, - pathMatchType: null - } + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + } as z.infer }); const watchedIp = addTargetForm.watch("ip"); @@ -313,6 +334,8 @@ export default function Page() { ...data, path: data.path || null, pathMatchType: data.pathMatchType || null, + rewritePath: data.rewritePath || null, + rewritePathType: data.rewritePathType || null, siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), @@ -326,7 +349,9 @@ export default function Page() { method: baseForm.watch("http") ? "http" : null, port: "" as any as number, path: null, - pathMatchType: null + pathMatchType: null, + rewritePath: null, + rewritePathType: null, }); } @@ -422,7 +447,9 @@ export default function Page() { enabled: target.enabled, siteId: target.siteId, path: target.path, - pathMatchType: target.pathMatchType + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType }; await api.put(`/resource/${id}/target`, data); @@ -549,93 +576,66 @@ export default function Page() { accessorKey: "path", header: t("matchPath"), cell: ({ row }) => { - const [showPathInput, setShowPathInput] = useState( - !!(row.original.path || row.original.pathMatchType) - ); + const hasPathMatch = !!(row.original.path || row.original.pathMatchType); - if (!showPathInput) { - return ( - - ); - } - - return ( -
- - { - const value = e.target.value.trim(); - if (!value) { - setShowPathInput(false); - updateTarget(row.original.targetId, { - ...row.original, - path: null, - pathMatchType: null - }); - } else { - updateTarget(row.original.targetId, { - ...row.original, - path: value - }); - } - }} /> - + {/* */}
+ ) : ( + updateTarget(row.original.targetId, config)} + trigger={ + + } + /> ); - } + }, }, { accessorKey: "siteId", @@ -820,6 +820,71 @@ export default function Page() { /> ) }, + { + accessorKey: "rewritePath", + header: t("rewritePath"), + cell: ({ row }) => { + const hasRewritePath = !!(row.original.rewritePath || row.original.rewritePathType); + const noPathMatch = !row.original.path && !row.original.pathMatchType; + + return hasRewritePath && !noPathMatch ? ( +
+ {/* */} + updateTarget(row.original.targetId, config)} + trigger={ + + } + /> + +
+ ) : ( + updateTarget(row.original.targetId, config)} + trigger={ + + } + disabled={noPathMatch} + /> + ); + }, + }, { accessorKey: "enabled", header: t("enabled"), diff --git a/src/components/PathMatchRenameModal.tsx b/src/components/PathMatchRenameModal.tsx new file mode 100644 index 00000000..574c9c70 --- /dev/null +++ b/src/components/PathMatchRenameModal.tsx @@ -0,0 +1,293 @@ +import { Pencil } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@app/components/ui/dialog"; +import { Badge } from "@app/components/ui/badge"; +import { Label } from "@app/components/ui/label"; +import { useEffect, useState } from "react"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; + + +export function PathMatchModal({ + value, + onChange, + trigger, +}: { + value: { path: string | null; pathMatchType: string | null }; + onChange: (config: { path: string | null; pathMatchType: string | null }) => void; + trigger: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + const [matchType, setMatchType] = useState(value?.pathMatchType || "prefix"); + const [path, setPath] = useState(value?.path || ""); + + useEffect(() => { + if (open) { + setMatchType(value?.pathMatchType || "prefix"); + setPath(value?.path || ""); + } + }, [open, value]); + + const handleSave = () => { + onChange({ pathMatchType: matchType as any, path: path.trim() }); + setOpen(false); + }; + + const handleClear = () => { + onChange({ pathMatchType: null, path: null }); + setOpen(false); + }; + + const getPlaceholder = () => (matchType === "regex" ? "^/api/.*" : "/path"); + + const getHelpText = () => { + switch (matchType) { + case "prefix": + return "Example: /api matches /api, /api/users, etc."; + case "exact": + return "Example: /api matches only /api"; + case "regex": + return "Example: ^/api/.* matches /api/anything"; + default: + return ""; + } + }; + + return ( + + {trigger} + + + Configure Path Matching + + Set up how incoming requests should be matched based on their path. + + +
+
+ + +
+
+ + setPath(e.target.value)} + /> +

{getHelpText()}

+
+
+ + {value?.path && ( + + )} + + +
+
+ ); +} + + +export function PathRewriteModal({ + value, + onChange, + trigger, + disabled, +}: { + value: { rewritePath: string | null; rewritePathType: string | null }; + onChange: (config: { rewritePath: string | null; rewritePathType: string | null }) => void; + trigger: React.ReactNode; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const [rewriteType, setRewriteType] = useState(value?.rewritePathType || "prefix"); + const [rewritePath, setRewritePath] = useState(value?.rewritePath || ""); + + useEffect(() => { + if (open) { + setRewriteType(value?.rewritePathType || "prefix"); + setRewritePath(value?.rewritePath || ""); + } + }, [open, value]); + + const handleSave = () => { + onChange({ rewritePathType: rewriteType as any, rewritePath: rewritePath.trim() }); + setOpen(false); + }; + + const handleClear = () => { + onChange({ rewritePathType: null, rewritePath: null }); + setOpen(false); + }; + + const getPlaceholder = () => { + switch (rewriteType) { + case "regex": + return "/new/$1"; + case "stripPrefix": + return ""; + default: + return "/new-path"; + } + }; + + const getHelpText = () => { + switch (rewriteType) { + case "prefix": + return "Replace the matched prefix with this value"; + case "exact": + return "Replace the entire path with this value"; + case "regex": + return "Use capture groups like $1, $2 for replacement"; + case "stripPrefix": + return "Leave empty to strip prefix or provide new prefix"; + default: + return ""; + } + }; + + return ( + + + {trigger} + + + + Configure Path Rewriting + + Transform the matched path before forwarding to the target. + + +
+
+ + +
+
+ + setRewritePath(e.target.value)} + /> +

{getHelpText()}

+
+
+ + {value?.rewritePath && ( + + )} + + +
+
+ ); +} + +export function PathMatchDisplay({ + value, +}: { + value: { path: string | null; pathMatchType: string | null }; +}) { + if (!value?.path) return null; + + const getTypeLabel = (type: string | null) => { + const labels: Record = { + prefix: "Prefix", + exact: "Exact", + regex: "Regex", + }; + return labels[type || ""] || type; + }; + + return ( +
+ + {getTypeLabel(value.pathMatchType)} + + + {value.path} + + +
+ ); +} + + +export function PathRewriteDisplay({ + value, +}: { + value: { rewritePath: string | null; rewritePathType: string | null }; +}) { + if (!value?.rewritePath && value?.rewritePathType !== "stripPrefix") return null; + + const getTypeLabel = (type: string | null) => { + const labels: Record = { + prefix: "Prefix", + exact: "Exact", + regex: "Regex", + stripPrefix: "Strip", + }; + return labels[type || ""] || type; + }; + + return ( +
+ + {getTypeLabel(value.rewritePathType)} + + + {value.rewritePath || (strip)} + + +
+ ); +}