This commit is contained in:
Owen
2026-07-03 17:36:06 -04:00
parent f60b8795ad
commit 2c3151da9b
2 changed files with 92 additions and 54 deletions

View File

@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState } from "react"; import { useActionState, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -137,11 +137,21 @@ function ProxyResourceHttpForm({
}); });
const [, formAction, saveLoading] = useActionState(onSubmit, null); const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() { async function onSubmit() {
const isValid = await form.trigger(); const isValid = await form.trigger();
if (!isValid) return; if (!isValid) return;
if (!headersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: t("headersValidationError")
});
return;
}
const data = form.getValues(); const data = form.getValues();
const res = await api const res = await api
@@ -318,6 +328,9 @@ function ProxyResourceHttpForm({
onChange={ onChange={
field.onChange field.onChange
} }
onValidityChange={
setHeadersValid
}
rows={4} rows={4}
/> />
</FormControl> </FormControl>
@@ -341,7 +354,7 @@ function ProxyResourceHttpForm({
<Button <Button
type="submit" type="submit"
loading={saveLoading} loading={saveLoading}
disabled={saveLoading} disabled={saveLoading || !headersValid}
form="http-settings-form" form="http-settings-form"
> >
{t("saveSettings")} {t("saveSettings")}

View File

@@ -2,24 +2,36 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface HeadersInputProps { interface HeadersInputProps {
value?: { name: string; value: string }[] | null; value?: { name: string; value: string }[] | null;
onChange: (value: { name: string; value: string }[] | null) => void; onChange: (value: { name: string; value: string }[] | null) => void;
onValidityChange?: (isValid: boolean) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
className?: string; className?: string;
} }
// Mirrors the server side validation in updateResource.ts so that invalid
// input is caught (and shown to the user) before it is ever submitted,
// instead of being silently dropped in favor of the last known good value.
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export function HeadersInput({ export function HeadersInput({
value = [], value = [],
onChange, onChange,
onValidityChange,
placeholder = `X-Example-Header: example-value placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`, X-Another-Header: another-value`,
rows = 4, rows = 4,
className className
}: HeadersInputProps) { }: HeadersInputProps) {
const t = useTranslations();
const [internalValue, setInternalValue] = useState(""); const [internalValue, setInternalValue] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false); const isUserEditingRef = useRef(false);
@@ -34,37 +46,56 @@ X-Another-Header: another-value`,
.join("\n"); .join("\n");
}; };
// Convert newline-separated string to header objects array // Parse newline-separated text into header objects, validating each line
const convertToHeadersArray = ( // against the same rules enforced by the server. Returns either the
// parsed headers or an error message describing the first invalid line.
const parseHeaders = (
newlineSeparated: string newlineSeparated: string
): { name: string; value: string }[] | null => { ):
if (!newlineSeparated || newlineSeparated.trim() === "") return []; | { headers: { name: string; value: string }[]; error: null }
| { headers: null; error: string } => {
if (!newlineSeparated || newlineSeparated.trim() === "") {
return { headers: [], error: null };
}
return newlineSeparated const lines = newlineSeparated
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0 && line.includes(":")) .filter((line) => line.length > 0);
.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 const headers: { name: string; value: string }[] = [];
// 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 }; for (const line of lines) {
}) const colonIndex = line.indexOf(":");
.filter((header) => header.name.length > 0); // Filter out headers with invalid names if (colonIndex === -1) {
return { headers: null, error: t("headersValidationError") };
}
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
if (
!validHeaderNamePattern.test(name) ||
!validHeaderValuePattern.test(value) ||
templatePattern.test(name) ||
templatePattern.test(value)
) {
return { headers: null, error: t("headersValidationError") };
}
headers.push({ name, value });
}
return { headers, error: null };
}; };
// Update internal value when external value changes // Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused) // But only if the user is not currently editing (textarea not focused)
useEffect(() => { useEffect(() => {
if (!isUserEditingRef.current) { if (!isUserEditingRef.current) {
setInternalValue(convertToNewlineSeparated(value)); setInternalValue(convertToNewlineSeparated(value ?? []));
setError(null);
onValidityChange?.(true);
} }
}, [value]); }, [value]);
@@ -75,31 +106,20 @@ X-Another-Header: another-value`,
// Mark that user is actively editing // Mark that user is actively editing
isUserEditingRef.current = true; isUserEditingRef.current = true;
// Only update parent if the input is in a valid state const result = parseHeaders(newValue);
// Valid states: empty/whitespace only, or contains properly formatted headers
if (newValue.trim() === "") { if (result.error) {
// Empty input is valid - represents no headers // Surface the error and do not touch the last known good value.
onChange([]); // Silently dropping the update here (without telling the user)
} else { // is what previously let stale data get saved without warning.
// Check if all non-empty lines are properly formatted (contain ':') setError(result.error);
const lines = newValue.split("\n"); onValidityChange?.(false);
const nonEmptyLines = lines return;
.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
} }
setError(null);
onValidityChange?.(true);
onChange(result.headers);
}; };
const handleFocus = () => { const handleFocus = () => {
@@ -114,15 +134,20 @@ X-Another-Header: another-value`,
}; };
return ( return (
<Textarea <div>
ref={textareaRef} <Textarea
value={internalValue} ref={textareaRef}
onChange={handleChange} value={internalValue}
onFocus={handleFocus} onChange={handleChange}
onBlur={handleBlur} onFocus={handleFocus}
placeholder={placeholder} onBlur={handleBlur}
rows={rows} placeholder={placeholder}
className={className} rows={rows}
/> className={className}
/>
{error && (
<p className="text-sm text-destructive mt-1.5">{error}</p>
)}
</div>
); );
} }