Преглед на файлове

feat(frontend): adapter between raw inbound rows and InboundFormValues

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.
MHSanaei преди 21 часа
родител
ревизия
629567db72
променени са 2 файла, в които са добавени 306 реда и са изтрити 0 реда
  1. 135 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  2. 171 0
      frontend/src/test/inbound-form-adapter.test.ts

+ 135 - 0
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<T> 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<string, unknown> {
+  if (value == null) return {};
+  if (typeof value === 'object' && !Array.isArray(value)) {
+    return value as Record<string, unknown>;
+  }
+  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<string, unknown>)
+      : {};
+  } 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;
+}

+ 171 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -0,0 +1,171 @@
+/// <reference types="vite/client" />
+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: '[email protected]',
+      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);
+  });
+});