From 629567db727eb79de2ba1389742921fbafcb34c8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 01:26:43 +0200 Subject: [PATCH] feat(frontend): adapter between raw inbound rows and InboundFormValues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lib/xray/inbound-form-adapter.ts with rawInboundToFormValues and formValuesToWirePayload. The pair is the data boundary the upcoming Pattern A modal will use: it consumes the DB row shape (settings et al. as string OR object — coerced internally), hands the modal typed InboundFormValues, and on submit reverses the trip to a wire payload with the three JSON-stringified slices the Go endpoints expect. No dependency on the legacy Inbound/DBInbound classes — the coerce step is inlined so the adapter survives the eventual models/ deletion. Adds 10 Vitest cases covering string vs object inputs, the optional streamSettings/nodeId fields, trafficReset coercion, and a raw-to-payload -to-raw round-trip equality. --- frontend/src/lib/xray/inbound-form-adapter.ts | 135 ++++++++++++++ .../src/test/inbound-form-adapter.test.ts | 171 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 frontend/src/lib/xray/inbound-form-adapter.ts create mode 100644 frontend/src/test/inbound-form-adapter.test.ts diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts new file mode 100644 index 00000000..02678688 --- /dev/null +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -0,0 +1,135 @@ +import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form'; +import type { InboundSettings } from '@/schemas/protocols/inbound'; +import type { StreamSettings } from '@/schemas/api/inbound'; +import type { Sniffing } from '@/schemas/primitives'; + +// Plain-data adapter between the panel's stored inbound row shape and +// the typed InboundFormValues that Form.useForm carries inside +// InboundFormModal. No dependency on the legacy Inbound/DBInbound +// classes — the modal hands the raw row in, takes typed values out, and +// on submit calls formValuesToWirePayload() to get a payload ready to +// POST to /panel/api/inbounds/add or /update/:id. + +export interface RawInboundRow { + port?: number; + listen?: string; + protocol?: string; + tag?: string; + settings?: unknown; + streamSettings?: unknown; + sniffing?: unknown; + up?: number; + down?: number; + total?: number; + remark?: string; + enable?: boolean; + expiryTime?: number; + trafficReset?: string; + lastTrafficResetTime?: number; + nodeId?: number | null; + clientStats?: unknown; +} + +// The wire payload — settings/streamSettings/sniffing arrive as JSON +// strings, mirroring what the Go endpoints expect (xray-core wants the +// nested config slices as strings to round-trip through its loader). +export interface WireInboundPayload { + up: number; + down: number; + total: number; + remark: string; + enable: boolean; + expiryTime: number; + trafficReset: TrafficReset; + lastTrafficResetTime: number; + listen: string; + port: number; + protocol: string; + settings: string; + streamSettings: string; + sniffing: string; + tag: string; + clientStats?: unknown; + nodeId?: number; +} + +function coerceJsonObject(value: unknown): Record { + if (value == null) return {}; + if (typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== 'string') return {}; + const trimmed = value.trim(); + if (trimmed === '') return {}; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly']; + +function coerceTrafficReset(v: unknown): TrafficReset { + return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v) + ? (v as TrafficReset) + : 'never'; +} + +// Map a raw DB row (settings/streamSettings/sniffing as string OR object) +// into the typed InboundFormValues. Does NOT validate against the schema — +// callers that want a hard guarantee should follow up with +// InboundFormSchema.safeParse(...). +export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { + const protocol = (row.protocol || 'vless') as InboundSettings['protocol']; + const settings = coerceJsonObject(row.settings) as InboundSettings['settings']; + const rawStream = coerceJsonObject(row.streamSettings); + const streamSettings = Object.keys(rawStream).length > 0 + ? (rawStream as StreamSettings) + : undefined; + const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing; + + return { + remark: row.remark ?? '', + enable: row.enable ?? true, + port: row.port ?? 0, + listen: row.listen ?? '', + tag: row.tag ?? '', + expiryTime: row.expiryTime ?? 0, + sniffing, + streamSettings, + up: row.up ?? 0, + down: row.down ?? 0, + total: row.total ?? 0, + trafficReset: coerceTrafficReset(row.trafficReset), + lastTrafficResetTime: row.lastTrafficResetTime ?? 0, + nodeId: row.nodeId ?? null, + protocol, + settings, + } as InboundFormValues; +} + +export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload { + const payload: WireInboundPayload = { + up: values.up, + down: values.down, + total: values.total, + remark: values.remark, + enable: values.enable, + expiryTime: values.expiryTime, + trafficReset: values.trafficReset, + lastTrafficResetTime: values.lastTrafficResetTime, + listen: values.listen, + port: values.port, + protocol: values.protocol, + settings: JSON.stringify(values.settings ?? {}), + streamSettings: values.streamSettings ? JSON.stringify(values.streamSettings) : '', + sniffing: JSON.stringify(values.sniffing ?? {}), + tag: values.tag, + }; + if (values.nodeId != null) payload.nodeId = values.nodeId; + return payload; +} diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts new file mode 100644 index 00000000..95662216 --- /dev/null +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -0,0 +1,171 @@ +/// +import { describe, expect, it } from 'vitest'; + +import { + rawInboundToFormValues, + formValuesToWirePayload, + type RawInboundRow, +} from '@/lib/xray/inbound-form-adapter'; +import { InboundFormSchema } from '@/schemas/forms/inbound-form'; + +// Round-trip: raw DB row → InboundFormValues → wire payload, asserting +// that the JSON-stringified settings/streamSettings/sniffing in the +// payload deserialize back to the same data the raw row carried. + +interface FixtureCase { + name: string; + row: RawInboundRow; + expectedProtocol: string; +} + +const vlessRow: RawInboundRow = { + id: 7, + port: 12345, + listen: '0.0.0.0', + protocol: 'vless', + remark: 'edge-1', + enable: true, + up: 1024, + down: 2048, + total: 1_000_000_000, + expiryTime: 0, + trafficReset: 'monthly', + lastTrafficResetTime: 0, + tag: 'inbound-1', + nodeId: null, + settings: { + clients: [{ + id: '8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02', + email: 'alice@example.test', + flow: '', + limitIp: 0, + totalGB: 0, + expiryTime: 0, + enable: true, + tgId: 0, + subId: 'abc123def', + comment: '', + reset: 0, + }], + decryption: 'none', + encryption: 'none', + fallbacks: [], + }, + streamSettings: { + network: 'tcp', + security: 'none', + tcpSettings: { header: { type: 'none' } }, + }, + sniffing: { + enabled: false, + destOverride: ['http', 'tls', 'quic', 'fakedns'], + metadataOnly: false, + routeOnly: false, + ipsExcluded: [], + domainsExcluded: [], + }, +} as RawInboundRow & { id: number }; + +const cases: FixtureCase[] = [ + { name: 'vless tcp none', row: vlessRow, expectedProtocol: 'vless' }, + { + name: 'string-coerced settings', + row: { + ...vlessRow, + settings: JSON.stringify(vlessRow.settings), + streamSettings: JSON.stringify(vlessRow.streamSettings), + sniffing: JSON.stringify(vlessRow.sniffing), + }, + expectedProtocol: 'vless', + }, + { + name: 'empty stream settings drop to undefined', + row: { ...vlessRow, streamSettings: '' }, + expectedProtocol: 'vless', + }, + { + name: 'unknown trafficReset coerces to never', + row: { ...vlessRow, trafficReset: 'totally-fabricated' }, + expectedProtocol: 'vless', + }, +]; + +describe('rawInboundToFormValues', () => { + for (const { name, row, expectedProtocol } of cases) { + it(`maps ${name}`, () => { + const values = rawInboundToFormValues(row); + expect(values.protocol).toBe(expectedProtocol); + expect(values.port).toBe(row.port); + expect(values.remark).toBe(row.remark ?? ''); + if (name === 'unknown trafficReset coerces to never') { + expect(values.trafficReset).toBe('never'); + } + if (name === 'empty stream settings drop to undefined') { + expect(values.streamSettings).toBeUndefined(); + } + }); + } + + it('produces values that the InboundFormSchema accepts', () => { + const values = rawInboundToFormValues(vlessRow); + const result = InboundFormSchema.safeParse(values); + expect(result.success).toBe(true); + }); +}); + +describe('formValuesToWirePayload', () => { + it('stringifies settings/streamSettings/sniffing', () => { + const values = rawInboundToFormValues(vlessRow); + const payload = formValuesToWirePayload(values); + + expect(typeof payload.settings).toBe('string'); + expect(typeof payload.streamSettings).toBe('string'); + expect(typeof payload.sniffing).toBe('string'); + + expect(JSON.parse(payload.settings)).toEqual(vlessRow.settings); + expect(JSON.parse(payload.streamSettings)).toEqual(vlessRow.streamSettings); + expect(JSON.parse(payload.sniffing)).toEqual(vlessRow.sniffing); + }); + + it('emits empty string for absent streamSettings', () => { + const values = rawInboundToFormValues({ ...vlessRow, streamSettings: '' }); + const payload = formValuesToWirePayload(values); + expect(payload.streamSettings).toBe(''); + }); + + it('omits nodeId when null', () => { + const values = rawInboundToFormValues({ ...vlessRow, nodeId: null }); + const payload = formValuesToWirePayload(values); + expect('nodeId' in payload).toBe(false); + }); + + it('includes nodeId when set', () => { + const values = rawInboundToFormValues({ ...vlessRow, nodeId: 42 }); + const payload = formValuesToWirePayload(values); + expect(payload.nodeId).toBe(42); + }); + + it('round-trips through raw → values → payload → values', () => { + const original = rawInboundToFormValues(vlessRow); + const payload = formValuesToWirePayload(original); + const replay = rawInboundToFormValues({ + port: payload.port, + listen: payload.listen, + protocol: payload.protocol, + tag: payload.tag, + settings: payload.settings, + streamSettings: payload.streamSettings, + sniffing: payload.sniffing, + up: payload.up, + down: payload.down, + total: payload.total, + remark: payload.remark, + enable: payload.enable, + expiryTime: payload.expiryTime, + trafficReset: payload.trafficReset, + lastTrafficResetTime: payload.lastTrafficResetTime, + nodeId: payload.nodeId ?? null, + }); + expect(replay).toEqual(original); + }); +});