mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
Headers input working on resource
This commit is contained in:
@@ -1505,5 +1505,8 @@
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider"
|
||||
}
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Custom Headers",
|
||||
"customHeadersDescription": "Headers new line separated: Header-Name: value",
|
||||
"headersValidationError": "Headers must be in the format: Header-Name: value"
|
||||
}
|
||||
@@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateHeaders(headers: string): boolean {
|
||||
// Validate comma-separated headers in format "Header-Name: value"
|
||||
const headerPairs = headers.split(",").map((pair) => pair.trim());
|
||||
return headerPairs.every((pair) => {
|
||||
// Check if the pair contains exactly one colon
|
||||
const colonCount = (pair.match(/:/g) || []).length;
|
||||
if (colonCount !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const colonIndex = pair.indexOf(":");
|
||||
if (colonIndex === 0 || colonIndex === pair.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerName = pair.substring(0, colonIndex).trim();
|
||||
const headerValue = pair.substring(colonIndex + 1).trim();
|
||||
|
||||
// Header name should not be empty and should contain valid characters
|
||||
// Header names are case-insensitive and can contain alphanumeric, hyphens
|
||||
const headerNameRegex = /^[a-zA-Z0-9\-_]+$/;
|
||||
if (!headerName || !headerNameRegex.test(headerName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Header value should not be empty and should not contain colons
|
||||
if (!headerValue || headerValue.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const validTlds = [
|
||||
"AAA",
|
||||
"AARP",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { registry } from "@server/openApi";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { validateHeaders } from "@server/lib/validators";
|
||||
|
||||
const updateResourceParamsSchema = z
|
||||
.object({
|
||||
@@ -45,7 +46,8 @@ const updateHttpResourceBodySchema = z
|
||||
stickySession: z.boolean().optional(),
|
||||
tlsServerName: 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()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
@@ -83,6 +85,18 @@ 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;
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
} from "@app/components/ui/command";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: z.string().refine(isTargetValid),
|
||||
@@ -129,7 +130,9 @@ export default function ReverseProxyTargets(props: {
|
||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map());
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const initializeDockerForSite = async (siteId: number) => {
|
||||
if (dockerStates.has(siteId)) {
|
||||
@@ -139,14 +142,14 @@ export default function ReverseProxyTargets(props: {
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const dockerState = await dockerManager.initializeDocker();
|
||||
|
||||
setDockerStates(prev => new Map(prev.set(siteId, dockerState)));
|
||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||
};
|
||||
|
||||
const refreshContainersForSite = async (siteId: number) => {
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const containers = await dockerManager.fetchContainers();
|
||||
|
||||
setDockerStates(prev => {
|
||||
setDockerStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existingState = newMap.get(siteId);
|
||||
if (existingState) {
|
||||
@@ -157,11 +160,13 @@ export default function ReverseProxyTargets(props: {
|
||||
};
|
||||
|
||||
const getDockerStateForSite = (siteId: number): DockerState => {
|
||||
return dockerStates.get(siteId) || {
|
||||
isEnabled: false,
|
||||
isAvailable: false,
|
||||
containers: []
|
||||
};
|
||||
return (
|
||||
dockerStates.get(siteId) || {
|
||||
isEnabled: false,
|
||||
isAvailable: false,
|
||||
containers: []
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
|
||||
@@ -185,7 +190,8 @@ export default function ReverseProxyTargets(props: {
|
||||
{
|
||||
message: t("proxyErrorInvalidHeader")
|
||||
}
|
||||
)
|
||||
),
|
||||
headers: z.string().optional()
|
||||
});
|
||||
|
||||
const tlsSettingsSchema = z.object({
|
||||
@@ -241,7 +247,8 @@ export default function ReverseProxyTargets(props: {
|
||||
const proxySettingsForm = useForm<ProxySettingsValues>({
|
||||
resolver: zodResolver(proxySettingsSchema),
|
||||
defaultValues: {
|
||||
setHostHeader: resource.setHostHeader || ""
|
||||
setHostHeader: resource.setHostHeader || "",
|
||||
headers: resource.headers || ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -298,7 +305,9 @@ export default function ReverseProxyTargets(props: {
|
||||
setSites(res.data.data.sites);
|
||||
|
||||
// Initialize Docker for newt sites
|
||||
const newtSites = res.data.data.sites.filter(site => site.type === "newt");
|
||||
const newtSites = res.data.data.sites.filter(
|
||||
(site) => site.type === "newt"
|
||||
);
|
||||
for (const site of newtSites) {
|
||||
initializeDockerForSite(site.siteId);
|
||||
}
|
||||
@@ -418,11 +427,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
|
||||
)
|
||||
);
|
||||
@@ -471,7 +480,8 @@ export default function ReverseProxyTargets(props: {
|
||||
stickySession: stickySessionData.stickySession,
|
||||
ssl: tlsData.ssl,
|
||||
tlsServerName: tlsData.tlsServerName || null,
|
||||
setHostHeader: proxyData.setHostHeader || null
|
||||
setHostHeader: proxyData.setHostHeader || null,
|
||||
headers: proxyData.headers || null
|
||||
};
|
||||
|
||||
// Single API call to update all settings
|
||||
@@ -483,7 +493,8 @@ export default function ReverseProxyTargets(props: {
|
||||
stickySession: stickySessionData.stickySession,
|
||||
ssl: tlsData.ssl,
|
||||
tlsServerName: tlsData.tlsServerName || null,
|
||||
setHostHeader: proxyData.setHostHeader || null
|
||||
setHostHeader: proxyData.setHostHeader || null,
|
||||
headers: proxyData.headers || null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -546,7 +557,7 @@ export default function ReverseProxyTargets(props: {
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!row.original.siteId &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{row.original.siteId
|
||||
@@ -597,49 +608,59 @@ export default function ReverseProxyTargets(props: {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedSite && selectedSite.type === "newt" && (() => {
|
||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={dockerState.containers}
|
||||
isAvailable={dockerState.isAvailable}
|
||||
onContainerSelect={handleContainerSelectForTarget}
|
||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{selectedSite &&
|
||||
selectedSite.type === "newt" &&
|
||||
(() => {
|
||||
const dockerState = getDockerStateForSite(
|
||||
selectedSite.siteId
|
||||
);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={dockerState.containers}
|
||||
isAvailable={dockerState.isAvailable}
|
||||
onContainerSelect={
|
||||
handleContainerSelectForTarget
|
||||
}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(
|
||||
selectedSite.siteId
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
...(resource.http
|
||||
? [
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: t("method"),
|
||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
]
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: t("method"),
|
||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "ip",
|
||||
@@ -658,9 +679,13 @@ export default function ReverseProxyTargets(props: {
|
||||
if (parsed) {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: hasProtocol ? parsed.protocol : row.original.method,
|
||||
method: hasProtocol
|
||||
? parsed.protocol
|
||||
: row.original.method,
|
||||
ip: parsed.host,
|
||||
port: hasPort ? parsed.port : row.original.port
|
||||
port: hasPort
|
||||
? parsed.port
|
||||
: row.original.port
|
||||
});
|
||||
} else {
|
||||
updateTarget(row.original.targetId, {
|
||||
@@ -807,21 +832,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"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -887,18 +912,35 @@ export default function ReverseProxyTargets(props: {
|
||||
);
|
||||
return selectedSite &&
|
||||
selectedSite.type ===
|
||||
"newt" ? (() => {
|
||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={dockerState.containers}
|
||||
isAvailable={dockerState.isAvailable}
|
||||
onContainerSelect={handleContainerSelect}
|
||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||
/>
|
||||
);
|
||||
})() : null;
|
||||
"newt"
|
||||
? (() => {
|
||||
const dockerState =
|
||||
getDockerStateForSite(
|
||||
selectedSite.siteId
|
||||
);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={
|
||||
selectedSite
|
||||
}
|
||||
containers={
|
||||
dockerState.containers
|
||||
}
|
||||
isAvailable={
|
||||
dockerState.isAvailable
|
||||
}
|
||||
onContainerSelect={
|
||||
handleContainerSelect
|
||||
}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(
|
||||
selectedSite.siteId
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
<FormMessage />
|
||||
@@ -964,25 +1006,59 @@ export default function ReverseProxyTargets(props: {
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("targetAddr")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="ip"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
const input = e.target.value.trim();
|
||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
||||
const input =
|
||||
e.target.value.trim();
|
||||
const hasProtocol =
|
||||
/^(https?|h2c):\/\//.test(
|
||||
input
|
||||
);
|
||||
const hasPort =
|
||||
/:\d+(?:\/|$)/.test(
|
||||
input
|
||||
);
|
||||
|
||||
if (hasProtocol || hasPort) {
|
||||
const parsed = parseHostTarget(input);
|
||||
if (
|
||||
hasProtocol ||
|
||||
hasPort
|
||||
) {
|
||||
const parsed =
|
||||
parseHostTarget(
|
||||
input
|
||||
);
|
||||
if (parsed) {
|
||||
if (hasProtocol || !addTargetForm.getValues("method")) {
|
||||
addTargetForm.setValue("method", parsed.protocol);
|
||||
if (
|
||||
hasProtocol ||
|
||||
!addTargetForm.getValues(
|
||||
"method"
|
||||
)
|
||||
) {
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
parsed.protocol
|
||||
);
|
||||
}
|
||||
addTargetForm.setValue("ip", parsed.host);
|
||||
if (hasPort || !addTargetForm.getValues("port")) {
|
||||
addTargetForm.setValue("port", parsed.port);
|
||||
addTargetForm.setValue(
|
||||
"ip",
|
||||
parsed.host
|
||||
);
|
||||
if (
|
||||
hasPort ||
|
||||
!addTargetForm.getValues(
|
||||
"port"
|
||||
)
|
||||
) {
|
||||
addTargetForm.setValue(
|
||||
"port",
|
||||
parsed.port
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1091,12 +1167,12 @@ export default function ReverseProxyTargets(props: {
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
@@ -1256,6 +1332,36 @@ export default function ReverseProxyTargets(props: {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={proxySettingsForm.control}
|
||||
name="headers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("customHeaders")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={
|
||||
field.value || ""
|
||||
}
|
||||
onChange={(value) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
}}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customHeadersDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
69
src/components/HeadersInput.tsx
Normal file
69
src/components/HeadersInput.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface HeadersInputProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HeadersInput({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = `X-Example-Header: example-value
|
||||
X-Another-Header: another-value`,
|
||||
rows = 4,
|
||||
className
|
||||
}: HeadersInputProps) {
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
|
||||
// Convert comma-separated to newline-separated for display
|
||||
const convertToNewlineSeparated = (commaSeparated: string): string => {
|
||||
if (!commaSeparated || commaSeparated.trim() === "") return "";
|
||||
|
||||
return commaSeparated
|
||||
.split(',')
|
||||
.map(header => header.trim())
|
||||
.filter(header => header.length > 0)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
// Convert newline-separated to comma-separated for output
|
||||
const convertToCommaSeparated = (newlineSeparated: string): string => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") return "";
|
||||
|
||||
return newlineSeparated
|
||||
.split('\n')
|
||||
.map(header => header.trim())
|
||||
.filter(header => header.length > 0)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// Update internal value when external value changes
|
||||
useEffect(() => {
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user