mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-30 08:59:34 +00:00
feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers
Add a single reusable header-map editor that handles the two wire
shapes Xray uses:
- v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria
masquerade. One value per name.
- v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage.
Each header can repeat (RFC 7230 §3.2.2).
Internal state is always a flat list of {name, value} rows regardless
of mode; conversion to/from the wire shape happens at the value /
onChange boundary so consumers bind straight to a Form.Item with no
extra transforms.
Wired into:
- InboundFormModal: WS Headers, HTTPUpgrade Headers
- OutboundFormModal: WS Headers, HTTPUpgrade Headers
XHTTP headers are already in a list-of-rows wire shape (different
from these two), so they keep their bespoke editor. Hysteria
masquerade is still deferred until the Hysteria stream sub-form
lands.
This commit is contained in:
122
frontend/src/components/HeaderMapEditor.tsx
Normal file
122
frontend/src/components/HeaderMapEditor.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Input, Space } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import InputAddon from '@/components/InputAddon';
|
||||
|
||||
// Reusable header-map editor. Handles the two wire shapes Xray uses for
|
||||
// HTTP-style header maps:
|
||||
//
|
||||
// v1: { 'Content-Type': 'application/json', 'X-Custom': 'value' }
|
||||
// Used by WS / HTTPUpgrade / Hysteria masquerade. One value per
|
||||
// name.
|
||||
//
|
||||
// v2: { 'Accept': ['text/html', 'application/json'],
|
||||
// 'X-Forwarded': ['1.2.3.4'] }
|
||||
// Used by TCP HTTP camouflage request/response. Each header can
|
||||
// repeat (RFC 7230 §3.2.2).
|
||||
//
|
||||
// Internal state is always the flat list-of-rows shape regardless of
|
||||
// mode. Conversion to/from the wire shape happens at the value/onChange
|
||||
// boundary so consumers can bind straight to a Form.Item without any
|
||||
// extra transforms on their side.
|
||||
|
||||
export type HeaderMapMode = 'v1' | 'v2';
|
||||
|
||||
export type HeaderMapValue =
|
||||
| Record<string, string>
|
||||
| Record<string, string[]>
|
||||
| undefined;
|
||||
|
||||
interface HeaderRow {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HeaderMapEditorProps {
|
||||
mode: HeaderMapMode;
|
||||
value?: HeaderMapValue;
|
||||
onChange?: (next: Record<string, string> | Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
function mapToRows(value: HeaderMapValue): HeaderRow[] {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const out: HeaderRow[] = [];
|
||||
for (const [name, raw] of Object.entries(value)) {
|
||||
if (Array.isArray(raw)) {
|
||||
for (const v of raw) {
|
||||
out.push({ name, value: typeof v === 'string' ? v : String(v) });
|
||||
}
|
||||
} else if (typeof raw === 'string') {
|
||||
out.push({ name, value: raw });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
|
||||
if (mode === 'v1') {
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) {
|
||||
if (!r.name) continue;
|
||||
map[r.name] = r.value ?? '';
|
||||
}
|
||||
return map;
|
||||
}
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const r of rows) {
|
||||
if (!r.name) continue;
|
||||
const list = map[r.name] ?? [];
|
||||
list.push(r.value ?? '');
|
||||
map[r.name] = list;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
|
||||
const rows = useMemo(() => mapToRows(value), [value]);
|
||||
|
||||
function commit(next: HeaderRow[]) {
|
||||
onChange?.(rowsToMap(next, mode));
|
||||
}
|
||||
|
||||
function setRow(index: number, patch: Partial<HeaderRow>) {
|
||||
const next = rows.slice();
|
||||
next[index] = { ...next[index], ...patch };
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
commit([...rows, { name: '', value: '' }]);
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
const next = rows.slice();
|
||||
next.splice(index, 1);
|
||||
commit(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.map((row, idx) => (
|
||||
<Space.Compact key={idx} block className="mb-8">
|
||||
<InputAddon>{`${idx + 1}`}</InputAddon>
|
||||
<Input
|
||||
value={row.name}
|
||||
placeholder="Name"
|
||||
onChange={(e) => setRow(idx, { name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder="Value"
|
||||
onChange={(e) => setRow(idx, { value: e.target.value })}
|
||||
/>
|
||||
<Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
|
||||
</Space.Compact>
|
||||
))}
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'
|
||||
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
|
||||
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
|
||||
import DateTimePicker from '@/components/DateTimePicker';
|
||||
import HeaderMapEditor from '@/components/HeaderMapEditor';
|
||||
import InputAddon from '@/components/InputAddon';
|
||||
import JsonEditor from '@/components/JsonEditor';
|
||||
import type { FormInstance } from 'antd';
|
||||
@@ -1255,6 +1256,12 @@ export default function InboundFormModal({
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Headers"
|
||||
name={['streamSettings', 'wsSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1486,6 +1493,12 @@ export default function InboundFormModal({
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Headers"
|
||||
name={['streamSettings', 'httpupgradeSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import HeaderMapEditor from '@/components/HeaderMapEditor';
|
||||
import InputAddon from '@/components/InputAddon';
|
||||
import JsonEditor from '@/components/JsonEditor';
|
||||
import { Wireguard } from '@/utils';
|
||||
@@ -1196,6 +1197,12 @@ export default function OutboundFormModal({
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Headers"
|
||||
name={['streamSettings', 'wsSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1237,6 +1244,12 @@ export default function OutboundFormModal({
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Headers"
|
||||
name={['streamSettings', 'httpupgradeSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user