mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: 'a@example.test',
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user