Store headers as json

This commit is contained in:
Owen
2025-09-21 15:49:50 -04:00
parent e94ded920b
commit 5d3c5ab7cc
7 changed files with 155 additions and 65 deletions

View File

@@ -42,7 +42,9 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) {
} }
} }
export type GetResourceResponse = NonNullable<Awaited<ReturnType<typeof query>>>; export type GetResourceResponse = Omit<NonNullable<Awaited<ReturnType<typeof query>>>, 'headers'> & {
headers: { name: string; value: string }[] | null;
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -99,7 +101,10 @@ export async function getResource(
} }
return response<GetResourceResponse>(res, { return response<GetResourceResponse>(res, {
data: resource, data: {
...resource,
headers: resource.headers ? JSON.parse(resource.headers) : resource.headers
},
success: true, success: true,
error: false, error: false,
message: "Resource retrieved successfully", message: "Resource retrieved successfully",

View File

@@ -47,7 +47,7 @@ const updateHttpResourceBodySchema = z
tlsServerName: z.string().nullable().optional(), tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(),
skipToIdpId: z.number().int().positive().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() .strict()
.refine((data) => Object.keys(data).length > 0, { .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." "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; export type UpdateResourceResponse = Resource;
@@ -292,9 +280,14 @@ async function updateHttpResource(
updateData.subdomain = finalSubdomain; updateData.subdomain = finalSubdomain;
} }
let headers = null;
if (updateData.headers) {
headers = JSON.stringify(updateData.headers);
}
const updatedResource = await db const updatedResource = await db
.update(resources) .update(resources)
.set({ ...updateData }) .set({ ...updateData, headers })
.where(eq(resources.resourceId, resource.resourceId)) .where(eq(resources.resourceId, resource.resourceId))
.returning(); .returning();

View File

@@ -306,22 +306,25 @@ export async function getTraefikConfig(
...additionalMiddlewares ...additionalMiddlewares
]; ];
if ( if (resource.headers || resource.setHostHeader) {
resource.headers ||
resource.setHostHeader
) {
// if there are headers, parse them into an object // if there are headers, parse them into an object
const headersObj: { [key: string]: string } = {}; const headersObj: { [key: string]: string } = {};
const headersArr = resource.headers?.split(","); if (resource.headers) {
if (headersArr && headersArr.length > 0) { let headersArr: { name: string; value: string }[] = [];
for (const header of headersArr) { try {
const [key, value] = header headersArr = JSON.parse(resource.headers) as {
.split(":") name: string;
.map((s: string) => s.trim()); value: string;
if (key && value) { }[];
headersObj[key] = value; } catch (e) {
} logger.warn(
`Failed to parse headers for resource ${resource.resourceId}: ${e}`
);
} }
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
} }
if (resource.setHostHeader) { if (resource.setHostHeader) {

View File

@@ -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;
}
}

View File

@@ -5,16 +5,16 @@ import path from "path";
const version = "1.10.1"; const version = "1.10.1";
export default async function migration() { 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 location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location); const db = new Database(location);
try { try {
db.pragma("foreign_keys = OFF"); db.pragma("foreign_keys = OFF");
db.transaction(() => { db.transaction(() => {
db.exec(`ALTER TABLE "targets" RENAME TO "targets_old"; db.exec(`ALTER TABLE "targets" RENAME TO "targets_old";
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "targets" ( CREATE TABLE "targets" (
"targetId" INTEGER PRIMARY KEY AUTOINCREMENT, "targetId" INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -57,13 +57,43 @@ SELECT
FROM "targets_old"; FROM "targets_old";
--> statement-breakpoint --> statement-breakpoint
DROP TABLE "targets_old";`); DROP TABLE "targets_old";`);
})(); })();
db.pragma("foreign_keys = ON"); db.pragma("foreign_keys = ON");
console.log(`Migrated database`); const resources = db.prepare("SELECT * FROM resources").all() as Array<{
} catch (e) { resourceId: number;
console.log("Failed to migrate db:", e); headers: string | null;
throw e; }>;
}
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;
}
} }

View File

@@ -227,7 +227,7 @@ export default function ReverseProxyTargets(props: {
message: t("proxyErrorInvalidHeader") message: t("proxyErrorInvalidHeader")
} }
), ),
headers: z.string().optional() headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable()
}); });
const tlsSettingsSchema = z.object({ const tlsSettingsSchema = z.object({
@@ -286,7 +286,7 @@ export default function ReverseProxyTargets(props: {
resolver: zodResolver(proxySettingsSchema), resolver: zodResolver(proxySettingsSchema),
defaultValues: { defaultValues: {
setHostHeader: resource.setHostHeader || "", setHostHeader: resource.setHostHeader || "",
headers: resource.headers || "" headers: resource.headers
} }
}); });
@@ -1479,7 +1479,7 @@ export default function ReverseProxyTargets(props: {
<FormControl> <FormControl>
<HeadersInput <HeadersInput
value={ value={
field.value || "" field.value
} }
onChange={(value) => { onChange={(value) => {
field.onChange( field.onChange(

View File

@@ -3,16 +3,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
interface HeadersInputProps { interface HeadersInputProps {
value?: string; value?: { name: string, value: string }[] | null;
onChange: (value: string) => void; onChange: (value: { name: string, value: string }[] | null) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
className?: string; className?: string;
} }
export function HeadersInput({ export function HeadersInput({
value = "", value = [],
onChange, onChange,
placeholder = `X-Example-Header: example-value placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`, X-Another-Header: another-value`,
@@ -21,26 +22,35 @@ X-Another-Header: another-value`,
}: HeadersInputProps) { }: HeadersInputProps) {
const [internalValue, setInternalValue] = useState(""); const [internalValue, setInternalValue] = useState("");
// Convert comma-separated to newline-separated for display // Convert header objects array to newline-separated string for display
const convertToNewlineSeparated = (commaSeparated: string): string => { const convertToNewlineSeparated = (headers: { name: string, value: string }[] | null): string => {
if (!commaSeparated || commaSeparated.trim() === "") return ""; if (!headers || headers.length === 0) return "";
return commaSeparated return headers
.split(',') .map(header => `${header.name}: ${header.value}`)
.map(header => header.trim())
.filter(header => header.length > 0)
.join('\n'); .join('\n');
}; };
// Convert newline-separated to comma-separated for output // Convert newline-separated string to header objects array
const convertToCommaSeparated = (newlineSeparated: string): string => { const convertToHeadersArray = (newlineSeparated: string): { name: string, value: string }[] | null => {
if (!newlineSeparated || newlineSeparated.trim() === "") return ""; if (!newlineSeparated || newlineSeparated.trim() === "") return [];
return newlineSeparated return newlineSeparated
.split('\n') .split('\n')
.map(header => header.trim()) .map(line => line.trim())
.filter(header => header.length > 0) .filter(line => line.length > 0 && line.includes(':'))
.join(', '); .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 // Update internal value when external value changes
@@ -52,9 +62,9 @@ X-Another-Header: another-value`,
const newValue = e.target.value; const newValue = e.target.value;
setInternalValue(newValue); setInternalValue(newValue);
// Convert back to comma-separated format for the parent // Convert back to header objects array for the parent
const commaSeparatedValue = convertToCommaSeparated(newValue); const headersArray = convertToHeadersArray(newValue);
onChange(commaSeparatedValue); onChange(headersArray);
}; };
return ( return (