Headers input working on resource

This commit is contained in:
Owen
2025-09-11 13:58:12 -07:00
parent 1eacb8ff36
commit 2efd5c31ab
5 changed files with 326 additions and 100 deletions

View File

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

View File

@@ -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",

View File

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

View File

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

View 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}
/>
);
}