Parcourir la source

fix(frontend): TProxy schema, VLESS+XHTTP flow links, clearable Jalali date picker (#5339, #5322, #5313)

- #5339: accept transportless tunnel/TProxy streamSettings that carry no
  `security` key by adding a transportless branch to SecuritySettingsSchema,
  mirroring NetworkSettingsSchema. Fixes "streamSettings.security Invalid input".
- #5322: emit XTLS Vision `flow` in panel VLESS share links for XHTTP+vlessenc
  via the shared canEnableTlsFlow predicate, so panel links match the form and
  the subscription output.
- #5313: give the Jalali expiry date picker a working clear (X) button
  (remount on clear, since the library reads `value` only on mount) and a blank
  placeholder instead of the library's hardcoded Persian text.
MHSanaei il y a 14 heures
Parent
commit
f00512d12e

+ 36 - 0
frontend/src/components/form/DateTimePicker.css

@@ -1,5 +1,6 @@
 .jdp-wrap {
   width: 100%;
+  position: relative;
 }
 
 .jdp-wrap > * {
@@ -33,3 +34,38 @@
   pointer-events: none;
   opacity: 0.6;
 }
+
+/* persian-calendar-suite has no allowClear; overlay our own clear button so
+   the Jalali picker matches the Gregorian AntD DatePicker's X affordance. */
+.jdp-wrap .jdp-clear {
+  position: absolute;
+  top: 50%;
+  right: 11px;
+  transform: translateY(-50%);
+  z-index: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: auto;
+  padding: 0;
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  font-size: 12px;
+  line-height: 1;
+  color: rgba(0, 0, 0, 0.25);
+  transition: color 0.2s;
+}
+
+.jdp-wrap .jdp-clear:hover {
+  color: rgba(0, 0, 0, 0.45);
+}
+
+.jdp-dark .jdp-clear {
+  color: rgba(255, 255, 255, 0.30);
+}
+
+.jdp-dark .jdp-clear:hover,
+.jdp-ultra .jdp-clear:hover {
+  color: rgba(255, 255, 255, 0.45);
+}

+ 33 - 2
frontend/src/components/form/DateTimePicker.tsx

@@ -1,4 +1,5 @@
-import { useMemo } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { CloseCircleFilled } from '@ant-design/icons';
 import { DatePicker } from 'antd';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
@@ -54,6 +55,10 @@ export default function DateTimePicker({
 }: DateTimePickerProps) {
   const { datepicker } = useDatepicker();
   const { isDark, isUltra } = useTheme();
+  const jalaliRef = useRef<HTMLDivElement>(null);
+  // Bumped on clear: persian-calendar-suite reads `value` only on mount, so
+  // remounting via key is the only way to reflect an externally cleared value.
+  const [clearNonce, setClearNonce] = useState(0);
 
   const persianTheme = useMemo(() => {
     if (isUltra) return ULTRA_DARK_THEME;
@@ -61,10 +66,21 @@ export default function DateTimePicker({
     return LIGHT_THEME;
   }, [isDark, isUltra]);
 
+  // The library hardcodes a Persian placeholder and exposes no working prop to
+  // override it, so clear it (or apply the caller's) on the input directly so
+  // the empty field shows no leftover Persian text. No dep array: re-apply
+  // after every render (incl. clear-remounts).
+  useEffect(() => {
+    if (datepicker !== 'jalalian') return;
+    const input = jalaliRef.current?.querySelector('input');
+    if (input) input.placeholder = placeholder;
+  });
+
   if (datepicker === 'jalalian') {
     return (
-      <div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
+      <div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
         <PersianDateTimePicker
+          key={clearNonce}
           value={value ? value.valueOf() : null}
           onChange={(next: number | string | null) => {
             if (next == null || next === '') {
@@ -80,6 +96,21 @@ export default function DateTimePicker({
           rtlCalendar
           theme={persianTheme}
         />
+        {value && !disabled && (
+          <button
+            type="button"
+            className="jdp-clear"
+            aria-label="clear"
+            onMouseDown={(e) => e.preventDefault()}
+            onClick={(e) => {
+              e.stopPropagation();
+              onChange(null);
+              setClearNonce((n) => n + 1);
+            }}
+          >
+            <CloseCircleFilled />
+          </button>
+        )}
       </div>
     );
   }

+ 14 - 3
frontend/src/lib/xray/inbound-link.ts

@@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
 import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
 
 import { getHeaderValue } from './headers';
+import { canEnableTlsFlow } from './protocol-capabilities';
 
 // Share-link generators. Each per-protocol fn takes a typed inbound plus
 // client overrides and returns a URL (or '' when the protocol doesn't
@@ -186,7 +187,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
   const stream = inbound.streamSettings;
   if (!stream) return '';
 
-  const tls = forceTls === 'same' ? stream.security : forceTls;
+  const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
   const obj: Record<string, unknown> = {
     v: '2',
     ps: remark,
@@ -382,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
       if (tls.settings.pinnedPeerCertSha256.length > 0) {
         params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
       }
-      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
     }
     applyExternalProxyTLSParams(externalProxy, params, security);
   } else if (security === 'reality') {
@@ -402,12 +402,23 @@ export function genVlessLink(input: GenVlessLinkInput): string {
       if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
       if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
       if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
-      if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
     }
   } else {
     params.set('security', 'none');
   }
 
+  // XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
+  // VLESS-level encryption stands in for transport TLS). Mirrors the backend's
+  // vlessFlowAllowed and the form's flow-field gating so panel link, share
+  // link and subscription agree.
+  if (flow.length > 0 && canEnableTlsFlow({
+    protocol: inbound.protocol,
+    settings: inbound.settings,
+    streamSettings: stream,
+  })) {
+    params.set('flow', flow);
+  }
+
   const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);
   url.hash = encodeURIComponent(remark);

+ 13 - 4
frontend/src/schemas/protocols/security/index.ts

@@ -15,9 +15,18 @@ export type Security = z.infer<typeof SecuritySchema>;
 // 'none' neither key appears. The Xray panel's StreamSettings class emits
 // `undefined` for the inactive branch which strips the key during JSON
 // serialization, so this DU faithfully describes what's on disk.
-export const SecuritySettingsSchema = z.discriminatedUnion('security', [
-  z.object({ security: z.literal('none') }),
-  z.object({ security: z.literal('tls'),     tlsSettings:     TlsStreamSettingsSchema }),
-  z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
+//
+// Tunnel (dokodemo-door / TProxy) is transportless and may carry only
+// `sockopt` — its streamSettings has no `security` key at all. The
+// transportless branch accepts that shape, mirroring NetworkSettingsSchema's
+// `network: never().optional()` handling. A present-but-invalid security
+// still fails both branches so a typo can't slip through.
+export const SecuritySettingsSchema = z.union([
+  z.discriminatedUnion('security', [
+    z.object({ security: z.literal('none') }),
+    z.object({ security: z.literal('tls'),     tlsSettings:     TlsStreamSettingsSchema }),
+    z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
+  ]),
+  z.object({ security: z.never().optional() }),
 ]);
 export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;

+ 91 - 0
frontend/src/test/inbound-link.test.ts

@@ -567,3 +567,94 @@ describe('external proxy pinned cert (pcs)', () => {
     expect(new URL(link).searchParams.has('pcs')).toBe(false);
   });
 });
+
+// #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP
+// when VLESS encryption (vlessenc) is on, matching the form's flow display
+// and the backend subscription. Gating is via canEnableTlsFlow.
+describe('genVlessLink flow gating (#5322)', () => {
+  function vlessXhttp(encryption: string) {
+    return InboundSchema.parse({
+      id: 1,
+      up: 0,
+      down: 0,
+      total: 0,
+      remark: 'vlessenc',
+      enable: true,
+      expiryTime: 0,
+      listen: '',
+      port: 443,
+      tag: 'inbound-vless-xhttp',
+      sniffing: {
+        enabled: false,
+        destOverride: [],
+        metadataOnly: false,
+        routeOnly: false,
+        ipsExcluded: [],
+        domainsExcluded: [],
+      },
+      protocol: 'vless',
+      settings: {
+        clients: [
+          {
+            id: '11111111-2222-3333-4444-555555555555',
+            email: '[email protected]',
+            flow: 'xtls-rprx-vision',
+            limitIp: 0,
+            totalGB: 0,
+            expiryTime: 0,
+            enable: true,
+            tgId: 0,
+            subId: 's1',
+            comment: '',
+            reset: 0,
+          },
+        ],
+        decryption: 'none',
+        encryption,
+        fallbacks: [],
+      },
+      streamSettings: {
+        network: 'xhttp',
+        xhttpSettings: {},
+        security: 'none',
+      },
+    });
+  }
+
+  const clientId = '11111111-2222-3333-4444-555555555555';
+
+  it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => {
+    const link = genVlessLink({
+      inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'),
+      address: 'example.test',
+      port: 443,
+      clientId,
+      flow: 'xtls-rprx-vision',
+    });
+    expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
+  });
+
+  it('omits flow for VLESS+XHTTP without vless encryption', () => {
+    const link = genVlessLink({
+      inbound: vlessXhttp('none'),
+      address: 'example.test',
+      port: 443,
+      clientId,
+      flow: 'xtls-rprx-vision',
+    });
+    expect(new URL(link).searchParams.has('flow')).toBe(false);
+  });
+
+  it('still emits flow for classic TCP+REALITY Vision', () => {
+    const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
+    const typed = InboundSchema.parse(raw);
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'example.test',
+      port: 443,
+      clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id,
+      flow: 'xtls-rprx-vision',
+    });
+    expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
+  });
+});