mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 12:19:50 +00:00
Fix #3374
This commit is contained in:
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user