mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-07 00:15:43 +00:00
Merge branch 'fosrl:main' into webauth
This commit is contained in:
@@ -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 <your_token_here>',
|
||||
'Content-Type': 'application/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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function addTargets(
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
sendToClient(newtId, {
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/${protocol}/add`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
method: "get",
|
||||
@@ -99,7 +101,10 @@ export async function getResource(
|
||||
}
|
||||
|
||||
return response<GetResourceResponse>(res, {
|
||||
data: resource,
|
||||
data: {
|
||||
...resource,
|
||||
headers: resource.headers ? JSON.parse(resource.headers) : resource.headers
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource retrieved successfully",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
47
server/setup/scriptsPg/1.10.2.ts
Normal file
47
server/setup/scriptsPg/1.10.2.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -66,4 +66,4 @@ DROP TABLE "targets_old";`);
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
server/setup/scriptsSqlite/1.10.2.ts
Normal file
54
server/setup/scriptsSqlite/1.10.2.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={
|
||||
field.value || ""
|
||||
field.value
|
||||
}
|
||||
onChange={(value) => {
|
||||
field.onChange(
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
|
||||
Reference in New Issue
Block a user