fix(api): 渠道请求头 - 补充自定义 Header 配置并应用到上游请求

Refs #39
This commit is contained in:
ddCat
2026-07-29 20:57:24 +08:00
parent 412399a473
commit 6de81049bf
21 changed files with 535 additions and 75 deletions

View File

@@ -0,0 +1,109 @@
import { Button, Input, Tooltip } from "antd";
import { Plus, Trash2 } from "lucide-react";
import type { ChangeEvent } from "react";
import type { ChannelHeader } from "@/stores/use-config-store";
const DEFAULT_USER_AGENT = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
const MAX_HEADER_COUNT = 32;
const BLOCKED_HEADERS = new Set([
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"host",
"content-length",
"content-type",
"accept",
"connection",
"proxy-connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"forwarded",
"x-goog-api-key",
]);
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
type Props = {
value?: ChannelHeader[];
onChange?: (headers: ChannelHeader[]) => void;
disabled?: boolean;
};
export function ChannelHeadersEditor({ value = [], onChange, disabled }: Props) {
const headers = Array.isArray(value) ? value : [];
const validation = validateChannelHeaders(headers);
const hasUserAgent = headers.some((header) => header.name.trim().toLowerCase() === "user-agent");
const updateHeader = (index: number, patch: Partial<ChannelHeader>) => {
onChange?.(headers.map((header, itemIndex) => (itemIndex === index ? { ...header, ...patch } : header)));
};
return (
<div className="space-y-2 rounded-md border border-border/70 bg-muted/[.1] p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">自定义请求头</div>
<div className="mt-0.5 break-all text-xs leading-5 text-foreground/50">默认发送 {DEFAULT_USER_AGENT};添加 User-Agent 后会覆盖默认值。</div>
</div>
{!hasUserAgent ? (
<Button size="small" disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "User-Agent", value: DEFAULT_USER_AGENT }])}>
添加 User-Agent
</Button>
) : null}
</div>
{headers.length ? (
<div className="space-y-2">
{headers.map((header, index) => {
const inputProps = {
"aria-label": `请求头 ${index + 1} 值`,
disabled,
value: header.value,
placeholder: "Header 值",
onChange: (event: ChangeEvent<HTMLInputElement>) => updateHeader(index, { value: event.target.value }),
};
return (
<div key={index} className="grid min-w-0 grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_32px] gap-2">
<Input aria-label={`请求头 ${index + 1} 名称`} disabled={disabled} value={header.name} placeholder="Header 名称" onChange={(event) => updateHeader(index, { name: event.target.value })} />
{/token|key|secret/i.test(header.name) ? <Input.Password {...inputProps} /> : <Input {...inputProps} />}
<Tooltip title="删除请求头">
<Button aria-label={`删除请求头 ${index + 1}`} className="size-8 p-0" disabled={disabled} icon={<Trash2 className="size-3.5" />} onClick={() => onChange?.(headers.filter((_, itemIndex) => itemIndex !== index))} />
</Tooltip>
</div>
);
})}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button size="small" type="dashed" icon={<Plus className="size-3.5" />} disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "", value: "" }])}>
添加请求头
</Button>
<span className={`text-xs ${validation ? "text-destructive" : "text-foreground/45"}`}>{validation || `${headers.length}/${MAX_HEADER_COUNT}`}</span>
</div>
</div>
);
}
export function validateChannelHeaders(headers?: ChannelHeader[]) {
if (!headers?.length) return "";
if (headers.length > MAX_HEADER_COUNT) return `自定义请求头最多支持 ${MAX_HEADER_COUNT} 项`;
const seen = new Set<string>();
let totalBytes = 0;
for (const header of headers) {
const name = header.name.trim();
const value = header.value.trim();
if (!name && !value) continue;
if (!name || !HEADER_NAME_PATTERN.test(name)) return "请求头名称无效,请使用标准 HTTP Header 名称";
const normalizedName = name.toLowerCase();
if (BLOCKED_HEADERS.has(normalizedName) || normalizedName.startsWith("x-canvas-") || normalizedName.startsWith("x-forwarded-")) return `${name} 由系统管理,不允许自定义`;
if (seen.has(normalizedName)) return `请求头不能重复:${name}`;
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) return `${name} 的值包含非法控制字符`;
if (name.length > 128 || value.length > 4096) return "单个请求头名称或值过长";
totalBytes += new TextEncoder().encode(name + value).length;
if (totalBytes > 16 * 1024) return "自定义请求头总大小不能超过 16KB";
seen.add(normalizedName);
}
return "";
}