diff --git a/blueprint.py b/blueprint.py index b4f1a99c..93d21da5 100644 --- a/blueprint.py +++ b/blueprint.py @@ -8,10 +8,10 @@ import base64 YAML_FILE_PATH = 'blueprint.yaml' # The API endpoint and headers from the curl request -API_URL = 'http://localhost:3004/v1/org/test/blueprint' +API_URL = 'http://api.pangolin.fossorial.io/v1/org/test/blueprint' HEADERS = { 'accept': '*/*', - 'Authorization': 'Bearer v7ix7xha1bmq2on.tzsden374mtmkeczm3tx44uzxsljnrst7nmg7ccr', + 'Authorization': 'Bearer ', 'Content-Type': 'application/json' } diff --git a/messages/de-DE.json b/messages/de-DE.json index b2ee6f13..ed5d2042 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", diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 6244fefa..c6ab6f40 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -138,12 +138,8 @@ export async function updateProxyResources( ? true : resourceData.ssl; let headers = ""; - for (const header of resourceData.headers || []) { - headers += `${header.name}: ${header.value},`; - } - // if there are headers, remove the trailing comma - if (headers.endsWith(",")) { - headers = headers.slice(0, -1); + if (resourceData.headers) { + headers = JSON.stringify(resourceData.headers); } if (existingResource) { @@ -169,7 +165,7 @@ export async function updateProxyResources( .update(resources) .set({ name: resourceData.name || "Unnamed Resource", - protocol: protocol || "http", + protocol: protocol || "tcp", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, @@ -461,7 +457,7 @@ export async function updateProxyResources( orgId, niceId: resourceNiceId, name: resourceData.name || "Unnamed Resource", - protocol: resourceData.protocol || "http", + protocol: protocol || "tcp", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 506c1c8d..6c13963a 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.10.1"; +export const APP_VERSION = "1.10.2"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 91a0ac3f..803c3e27 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -15,7 +15,7 @@ export async function addTargets( }:${target.port}`; }); - sendToClient(newtId, { + await sendToClient(newtId, { type: `newt/${protocol}/add`, data: { targets: payloadTargets 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/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..069dd4f9 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, { @@ -85,18 +85,6 @@ const updateHttpResourceBodySchema = z message: "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(); @@ -342,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) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index a1a2a7a3..5101de84 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -306,17 +306,25 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - if (resource.headers && resource.headers.length > 0) { + 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 (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/migrationsPg.ts b/server/setup/migrationsPg.ts index c5950e1d..04779f30 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -10,6 +10,7 @@ import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; import m4 from "./scriptsPg/1.9.0"; import m5 from "./scriptsPg/1.10.0"; +import m6 from "./scriptsPg/1.10.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -21,6 +22,7 @@ const migrations = [ { version: "1.8.0", run: m3 }, { version: "1.9.0", run: m4 }, { version: "1.10.0", run: m5 }, + { version: "1.10.2", run: m6 }, // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index b8fa64f0..654c2716 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -28,6 +28,7 @@ import m23 from "./scriptsSqlite/1.8.0"; import m24 from "./scriptsSqlite/1.9.0"; import m25 from "./scriptsSqlite/1.10.0"; import m26 from "./scriptsSqlite/1.10.1"; +import m27 from "./scriptsSqlite/1.10.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -55,6 +56,7 @@ const migrations = [ { version: "1.9.0", run: m24 }, { version: "1.10.0", run: m25 }, { version: "1.10.1", run: m26 }, + { version: "1.10.2", run: m27 }, // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.10.2.ts b/server/setup/scriptsPg/1.10.2.ts new file mode 100644 index 00000000..e59901a5 --- /dev/null +++ b/server/setup/scriptsPg/1.10.2.ts @@ -0,0 +1,47 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; + +const version = "1.10.2"; + +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..f6f9894e 100644 --- a/server/setup/scriptsSqlite/1.10.1.ts +++ b/server/setup/scriptsSqlite/1.10.1.ts @@ -66,4 +66,4 @@ DROP TABLE "targets_old";`); console.log("Failed to migrate db:", e); throw e; } -} +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.10.2.ts b/server/setup/scriptsSqlite/1.10.2.ts new file mode 100644 index 00000000..7978e262 --- /dev/null +++ b/server/setup/scriptsSqlite/1.10.2.ts @@ -0,0 +1,54 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.10.2"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + const resources = db.prepare("SELECT * FROM resources").all() as Array<{ + resourceId: number; + headers: string | null; + }>; + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + 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` + ); + } + } + })(); + + db.pragma("foreign_keys = ON"); + + 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..7e52fa9e 100644 --- a/src/components/HeadersInput.tsx +++ b/src/components/HeadersInput.tsx @@ -1,18 +1,19 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } 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`, @@ -20,47 +21,98 @@ X-Another-Header: another-value`, className }: HeadersInputProps) { const [internalValue, setInternalValue] = useState(""); + const textareaRef = useRef(null); + const isUserEditingRef = useRef(false); - // 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 + // 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 comma-separated format for the parent - const commaSeparatedValue = convertToCommaSeparated(newValue); - onChange(commaSeparatedValue); + // 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 (