mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 04:17:15 +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 {
|
.jdp-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jdp-wrap > * {
|
.jdp-wrap > * {
|
||||||
@@ -33,3 +34,38 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.6;
|
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 { DatePicker } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
@@ -54,6 +55,10 @@ export default function DateTimePicker({
|
|||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
const { isDark, isUltra } = useTheme();
|
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(() => {
|
const persianTheme = useMemo(() => {
|
||||||
if (isUltra) return ULTRA_DARK_THEME;
|
if (isUltra) return ULTRA_DARK_THEME;
|
||||||
@@ -61,10 +66,21 @@ export default function DateTimePicker({
|
|||||||
return LIGHT_THEME;
|
return LIGHT_THEME;
|
||||||
}, [isDark, isUltra]);
|
}, [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') {
|
if (datepicker === 'jalalian') {
|
||||||
return (
|
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
|
<PersianDateTimePicker
|
||||||
|
key={clearNonce}
|
||||||
value={value ? value.valueOf() : null}
|
value={value ? value.valueOf() : null}
|
||||||
onChange={(next: number | string | null) => {
|
onChange={(next: number | string | null) => {
|
||||||
if (next == null || next === '') {
|
if (next == null || next === '') {
|
||||||
@@ -80,6 +96,21 @@ export default function DateTimePicker({
|
|||||||
rtlCalendar
|
rtlCalendar
|
||||||
theme={persianTheme}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
|
|||||||
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
|
||||||
import { getHeaderValue } from './headers';
|
import { getHeaderValue } from './headers';
|
||||||
|
import { canEnableTlsFlow } from './protocol-capabilities';
|
||||||
|
|
||||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||||
// client overrides and returns a URL (or '' when the protocol doesn't
|
// 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;
|
const stream = inbound.streamSettings;
|
||||||
if (!stream) return '';
|
if (!stream) return '';
|
||||||
|
|
||||||
const tls = forceTls === 'same' ? stream.security : forceTls;
|
const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
|
||||||
const obj: Record<string, unknown> = {
|
const obj: Record<string, unknown> = {
|
||||||
v: '2',
|
v: '2',
|
||||||
ps: remark,
|
ps: remark,
|
||||||
@@ -382,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
||||||
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
||||||
}
|
}
|
||||||
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
|
||||||
}
|
}
|
||||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||||
} else if (security === 'reality') {
|
} 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.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||||
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
|
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 (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||||
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
params.set('security', 'none');
|
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}`);
|
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
|
||||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
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
|
// 'none' neither key appears. The Xray panel's StreamSettings class emits
|
||||||
// `undefined` for the inactive branch which strips the key during JSON
|
// `undefined` for the inactive branch which strips the key during JSON
|
||||||
// serialization, so this DU faithfully describes what's on disk.
|
// serialization, so this DU faithfully describes what's on disk.
|
||||||
export const SecuritySettingsSchema = z.discriminatedUnion('security', [
|
//
|
||||||
z.object({ security: z.literal('none') }),
|
// Tunnel (dokodemo-door / TProxy) is transportless and may carry only
|
||||||
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
|
// `sockopt` — its streamSettings has no `security` key at all. The
|
||||||
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
|
// 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>;
|
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);
|
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