From fda9e9578677eb6761e35cf58eda71c0b1e9403e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Sep 2025 21:52:02 -0400 Subject: [PATCH 01/15] Add header for host all the time --- server/routers/traefik/getTraefikConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index a1a2a7a3..91f13b64 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -306,10 +306,10 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - if (resource.headers && resource.headers.length > 0) { + if ((resource.headers && resource.headers.length > 0) || resource.setHostHeader) { // if there are headers, parse them into an object const headersObj: { [key: string]: string } = {}; - const headersArr = resource.headers.split(","); + const headersArr = resource.headers?.split(","); for (const header of headersArr) { const [key, value] = header .split(":") From c882fbd59a24096f5480aa5a809eae8b50739e92 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 20 Sep 2025 09:51:23 -0400 Subject: [PATCH 02/15] New translations en-us.json (German) --- messages/de-DE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index d476b313..9134619c 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -454,7 +454,7 @@ "accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.", "userSaved": "Benutzer gespeichert", "userSavedDescription": "Der Benutzer wurde aktualisiert.", - "autoProvisioned": "Automatisch vorgesehen", + "autoProvisioned": "Automatisch bereitgestellt", "autoProvisionedDescription": "Erlaube diesem Benutzer die automatische Verwaltung durch Identitätsanbieter", "accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann", "accessControlsSubmit": "Zugriffskontrollen speichern", From e94ded920b9fe9be3bdf42654fd84d949df6aa90 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Sep 2025 11:42:51 -0400 Subject: [PATCH 03/15] Fix #1501 --- server/routers/traefik/getTraefikConfig.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 91f13b64..a304696b 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -306,16 +306,21 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - if ((resource.headers && resource.headers.length > 0) || resource.setHostHeader) { + if ( + resource.headers || + resource.setHostHeader + ) { // if there are headers, parse them into an object const headersObj: { [key: string]: string } = {}; const headersArr = resource.headers?.split(","); - for (const header of headersArr) { - const [key, value] = header - .split(":") - .map((s: string) => s.trim()); - if (key && value) { - headersObj[key] = value; + if (headersArr && headersArr.length > 0) { + for (const header of headersArr) { + const [key, value] = header + .split(":") + .map((s: string) => s.trim()); + if (key && value) { + headersObj[key] = value; + } } } From 5d3c5ab7cc35eba5dcba9f2027060ea0a6de1def Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Sep 2025 15:49:50 -0400 Subject: [PATCH 04/15] Store headers as json --- server/routers/resource/getResource.ts | 9 ++- server/routers/resource/updateResource.ts | 21 +++---- server/routers/traefik/getTraefikConfig.ts | 29 +++++----- server/setup/scriptsPg/1.10.1.ts | 49 ++++++++++++++++ server/setup/scriptsSqlite/1.10.1.ts | 58 ++++++++++++++----- .../resources/[niceId]/proxy/page.tsx | 6 +- src/components/HeadersInput.tsx | 48 +++++++++------ 7 files changed, 155 insertions(+), 65 deletions(-) create mode 100644 server/setup/scriptsPg/1.10.1.ts diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index d2aebedd..0fdcdd0c 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -42,7 +42,9 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) { } } -export type GetResourceResponse = NonNullable>>; +export type GetResourceResponse = Omit>>, 'headers'> & { + headers: { name: string; value: string }[] | null; +}; registry.registerPath({ method: "get", @@ -99,7 +101,10 @@ export async function getResource( } return response(res, { - data: resource, + data: { + ...resource, + headers: resource.headers ? JSON.parse(resource.headers) : resource.headers + }, success: true, error: false, message: "Resource retrieved successfully", diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 7c0f9c63..00003789 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -47,7 +47,7 @@ const updateHttpResourceBodySchema = z tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), skipToIdpId: z.number().int().positive().nullable().optional(), - headers: z.string().nullable().optional() + headers: z.array(z.object({ name: z.string(), value: z.string() })).optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -86,18 +86,6 @@ const updateHttpResourceBodySchema = z "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." } ) - .refine( - (data) => { - if (data.headers) { - return validateHeaders(data.headers); - } - return true; - }, - { - message: - "Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons." - } - ); export type UpdateResourceResponse = Resource; @@ -292,9 +280,14 @@ async function updateHttpResource( updateData.subdomain = finalSubdomain; } + let headers = null; + if (updateData.headers) { + headers = JSON.stringify(updateData.headers); + } + const updatedResource = await db .update(resources) - .set({ ...updateData }) + .set({ ...updateData, headers }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index a304696b..5101de84 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -306,22 +306,25 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - if ( - resource.headers || - resource.setHostHeader - ) { + if (resource.headers || resource.setHostHeader) { // if there are headers, parse them into an object const headersObj: { [key: string]: string } = {}; - const headersArr = resource.headers?.split(","); - if (headersArr && headersArr.length > 0) { - for (const header of headersArr) { - const [key, value] = header - .split(":") - .map((s: string) => s.trim()); - if (key && value) { - headersObj[key] = value; - } + if (resource.headers) { + let headersArr: { name: string; value: string }[] = []; + try { + headersArr = JSON.parse(resource.headers) as { + name: string; + value: string; + }[]; + } catch (e) { + logger.warn( + `Failed to parse headers for resource ${resource.resourceId}: ${e}` + ); } + + headersArr.forEach((header) => { + headersObj[header.name] = header.value; + }); } if (resource.setHostHeader) { diff --git a/server/setup/scriptsPg/1.10.1.ts b/server/setup/scriptsPg/1.10.1.ts new file mode 100644 index 00000000..48fa9f23 --- /dev/null +++ b/server/setup/scriptsPg/1.10.1.ts @@ -0,0 +1,49 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import { readFileSync } from "fs"; +import path, { join } from "path"; + +const version = "1.10.1"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + const resources = await db.execute(sql` + SELECT * FROM "resources" + `); + + await db.execute(sql`BEGIN`); + + for (const resource of resources.rows) { + const headers = resource.headers as string | null; + if (headers && headers !== "") { + // lets convert it to json + // fist split at commas + const headersArray = headers + .split(",") + .map((header: string) => { + const [name, ...valueParts] = header.split(":"); + const value = valueParts.join(":").trim(); + return { name: name.trim(), value }; + }); + + await db.execute(sql` + UPDATE "resources" SET "headers" = ${JSON.stringify(headersArray)} WHERE "resourceId" = ${resource.resourceId} + `); + + console.log( + `Updated resource ${resource.resourceId} headers to JSON format` + ); + } + } + + await db.execute(sql`COMMIT`); + console.log(`Migrated database`); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Failed to migrate db:", e); + throw e; + } +} diff --git a/server/setup/scriptsSqlite/1.10.1.ts b/server/setup/scriptsSqlite/1.10.1.ts index 3608e92e..292a9d95 100644 --- a/server/setup/scriptsSqlite/1.10.1.ts +++ b/server/setup/scriptsSqlite/1.10.1.ts @@ -5,16 +5,16 @@ import path from "path"; const version = "1.10.1"; export default async function migration() { - console.log(`Running setup script ${version}...`); + console.log(`Running setup script ${version}...`); - const location = path.join(APP_PATH, "db", "db.sqlite"); - const db = new Database(location); + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); - try { - db.pragma("foreign_keys = OFF"); + try { + db.pragma("foreign_keys = OFF"); - db.transaction(() => { - db.exec(`ALTER TABLE "targets" RENAME TO "targets_old"; + db.transaction(() => { + db.exec(`ALTER TABLE "targets" RENAME TO "targets_old"; --> statement-breakpoint CREATE TABLE "targets" ( "targetId" INTEGER PRIMARY KEY AUTOINCREMENT, @@ -57,13 +57,43 @@ SELECT FROM "targets_old"; --> statement-breakpoint DROP TABLE "targets_old";`); - })(); + })(); - db.pragma("foreign_keys = ON"); + db.pragma("foreign_keys = ON"); - console.log(`Migrated database`); - } catch (e) { - console.log("Failed to migrate db:", e); - throw e; - } + const resources = db.prepare("SELECT * FROM resources").all() as Array<{ + resourceId: number; + headers: string | null; + }>; + + for (const resource of resources) { + const headers = resource.headers; + if (headers && headers !== "") { + // lets convert it to json + // fist split at commas + const headersArray = headers + .split(",") + .map((header: string) => { + const [name, ...valueParts] = header.split(":"); + const value = valueParts.join(":").trim(); + return { name: name.trim(), value }; + }); + + db.prepare( + ` + UPDATE "resources" SET "headers" = ? WHERE "resourceId" = ? + ` + ).run(JSON.stringify(headersArray), resource.resourceId); + + console.log( + `Updated resource ${resource.resourceId} headers to JSON format` + ); + } + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } } diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index ba71a765..a2f95313 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -227,7 +227,7 @@ export default function ReverseProxyTargets(props: { message: t("proxyErrorInvalidHeader") } ), - headers: z.string().optional() + headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable() }); const tlsSettingsSchema = z.object({ @@ -286,7 +286,7 @@ export default function ReverseProxyTargets(props: { resolver: zodResolver(proxySettingsSchema), defaultValues: { setHostHeader: resource.setHostHeader || "", - headers: resource.headers || "" + headers: resource.headers } }); @@ -1479,7 +1479,7 @@ export default function ReverseProxyTargets(props: { { field.onChange( diff --git a/src/components/HeadersInput.tsx b/src/components/HeadersInput.tsx index 35f3e587..681561e8 100644 --- a/src/components/HeadersInput.tsx +++ b/src/components/HeadersInput.tsx @@ -3,16 +3,17 @@ import { useEffect, useState } from "react"; import { Textarea } from "@/components/ui/textarea"; + interface HeadersInputProps { - value?: string; - onChange: (value: string) => void; + value?: { name: string, value: string }[] | null; + onChange: (value: { name: string, value: string }[] | null) => void; placeholder?: string; rows?: number; className?: string; } export function HeadersInput({ - value = "", + value = [], onChange, placeholder = `X-Example-Header: example-value X-Another-Header: another-value`, @@ -21,26 +22,35 @@ X-Another-Header: another-value`, }: HeadersInputProps) { const [internalValue, setInternalValue] = useState(""); - // Convert comma-separated to newline-separated for display - const convertToNewlineSeparated = (commaSeparated: string): string => { - if (!commaSeparated || commaSeparated.trim() === "") return ""; + // Convert header objects array to newline-separated string for display + const convertToNewlineSeparated = (headers: { name: string, value: string }[] | null): string => { + if (!headers || headers.length === 0) return ""; - return commaSeparated - .split(',') - .map(header => header.trim()) - .filter(header => header.length > 0) + return headers + .map(header => `${header.name}: ${header.value}`) .join('\n'); }; - // Convert newline-separated to comma-separated for output - const convertToCommaSeparated = (newlineSeparated: string): string => { - if (!newlineSeparated || newlineSeparated.trim() === "") return ""; + // Convert newline-separated string to header objects array + const convertToHeadersArray = (newlineSeparated: string): { name: string, value: string }[] | null => { + if (!newlineSeparated || newlineSeparated.trim() === "") return []; return newlineSeparated .split('\n') - .map(header => header.trim()) - .filter(header => header.length > 0) - .join(', '); + .map(line => line.trim()) + .filter(line => line.length > 0 && line.includes(':')) + .map(line => { + const colonIndex = line.indexOf(':'); + const name = line.substring(0, colonIndex).trim(); + const value = line.substring(colonIndex + 1).trim(); + + // Ensure header name conforms to HTTP header requirements + // Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens + const normalizedName = name.replace(/[^a-zA-Z0-9\-]/g, '').toLowerCase(); + + return { name: normalizedName, value }; + }) + .filter(header => header.name.length > 0); // Filter out headers with invalid names }; // Update internal value when external value changes @@ -52,9 +62,9 @@ X-Another-Header: another-value`, const newValue = e.target.value; setInternalValue(newValue); - // Convert back to comma-separated format for the parent - const commaSeparatedValue = convertToCommaSeparated(newValue); - onChange(commaSeparatedValue); + // Convert back to header objects array for the parent + const headersArray = convertToHeadersArray(newValue); + onChange(headersArray); }; return ( From 9a41cac6e1435dc489c58a3de1e8e5799b871054 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Sep 2025 16:16:41 -0400 Subject: [PATCH 05/15] Remove port checks --- server/routers/resource/createResource.ts | 20 ------------------ server/routers/resource/updateResource.ts | 25 ----------------------- 2 files changed, 45 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index c5f30f83..60dfc0cb 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -319,26 +319,6 @@ async function createRawResource( const { name, http, protocol, proxyPort } = parsedBody.data; - // if http is false check to see if there is already a resource with the same port and protocol - const existingResource = await db - .select() - .from(resources) - .where( - and( - eq(resources.protocol, protocol), - eq(resources.proxyPort, proxyPort!) - ) - ); - - if (existingResource.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that protocol and port already exists" - ) - ); - } - let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 00003789..cf254b82 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -335,31 +335,6 @@ async function updateRawResource( const updateData = parsedBody.data; - if (updateData.proxyPort) { - const proxyPort = updateData.proxyPort; - const existingResource = await db - .select() - .from(resources) - .where( - and( - eq(resources.protocol, resource.protocol), - eq(resources.proxyPort, proxyPort!) - ) - ); - - if ( - existingResource.length > 0 && - existingResource[0].resourceId !== resource.resourceId - ) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that protocol and port already exists" - ) - ); - } - } - const updatedResource = await db .update(resources) .set(updateData) From d523ae3ffae70aab43ae4b01857260ef3d62218b Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Sep 2025 16:39:40 -0400 Subject: [PATCH 06/15] Fix input overwriting value --- src/components/HeadersInput.tsx | 52 +++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/components/HeadersInput.tsx b/src/components/HeadersInput.tsx index 681561e8..7e52fa9e 100644 --- a/src/components/HeadersInput.tsx +++ b/src/components/HeadersInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { Textarea } from "@/components/ui/textarea"; @@ -21,6 +21,8 @@ X-Another-Header: another-value`, className }: HeadersInputProps) { const [internalValue, setInternalValue] = useState(""); + const textareaRef = useRef(null); + const isUserEditingRef = useRef(false); // Convert header objects array to newline-separated string for display const convertToNewlineSeparated = (headers: { name: string, value: string }[] | null): string => { @@ -54,23 +56,63 @@ X-Another-Header: another-value`, }; // Update internal value when external value changes + // But only if the user is not currently editing (textarea not focused) useEffect(() => { - setInternalValue(convertToNewlineSeparated(value)); + if (!isUserEditingRef.current) { + setInternalValue(convertToNewlineSeparated(value)); + } }, [value]); const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setInternalValue(newValue); - // Convert back to header objects array for the parent - const headersArray = convertToHeadersArray(newValue); - onChange(headersArray); + // Mark that user is actively editing + isUserEditingRef.current = true; + + // Only update parent if the input is in a valid state + // Valid states: empty/whitespace only, or contains properly formatted headers + + if (newValue.trim() === "") { + // Empty input is valid - represents no headers + onChange([]); + } else { + // Check if all non-empty lines are properly formatted (contain ':') + const lines = newValue.split('\n'); + const nonEmptyLines = lines + .map(line => line.trim()) + .filter(line => line.length > 0); + + // If there are no non-empty lines, or all non-empty lines contain ':', it's valid + const isValid = nonEmptyLines.length === 0 || nonEmptyLines.every(line => line.includes(':')); + + if (isValid) { + // Safe to convert and update parent + const headersArray = convertToHeadersArray(newValue); + onChange(headersArray); + } + // If not valid, don't call onChange - let user continue typing + } + }; + + const handleFocus = () => { + isUserEditingRef.current = true; + }; + + const handleBlur = () => { + // Small delay to allow any final change events to process + setTimeout(() => { + isUserEditingRef.current = false; + }, 100); }; return (