mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
Add per-client external link controls (#5650)
* Add enable toggle for external client links * Document external link enable API fields * Extend external client link metadata * Fix external subscription cache status updates * fix(sub): address the review on per-client external link controls Blocking: the expiry filter dropped legacy rows. expiry_time was added without a default, so AutoMigrate makes it nullable and backfills NULL, and `expiry_time = 0 OR expiry_time > ?` is false for NULL under three-valued logic — every external link written before the upgrade vanished from all subscriptions. Add `default:0` on expiry_time and last_fetch_at, make the predicate NULL-tolerant, and backfill the NULLs a pre-fix build could already have written. Rework fetch-status recording. It ran inside the singleflight in-flight window, so every goroutine parked on the shared fetch waited for a DB write to commit on the public, unauthenticated subscription path — and because it was keyed on the row id, waiters and cache hits recorded nothing, leaving rows that lost the race stuck on "Not fetched yet" forever. fetchSubscriptionLinks now reports whether it did the network fetch and expandEntry records afterwards, off the serving path, keyed on kind+value so every row sharing the URL is stamped by the one fetch. Keying on value also closes the recycled-rowid hazard: saves delete and re-insert rows, and SQLite reuses rowids, so an in-flight write could land on an unrelated client's row. The write no longer discards its error either. Drop the inert id round-trip. The panel never sent it, and the byId branch was guarded by the exact kind+value equality that byKindValue already keys on, so it could not change an outcome. Matching on kind+value alone is what actually preserves fetch status across saves. Reject a negative expiryTime instead of storing a row that is silently invisible in every subscription — elsewhere a negative expiryTime means "a duration from first use", so an API caller reusing that convention got no error and no links. Drop the ~50 lines of .client-form-* / .client-inbounds-field CSS that no component renders; it is leftover from the WireGuard PR this one was split from. i18n: reuse the already-translated pages.inbounds.leaveBlankToNeverExpire instead of shipping an English duplicate under pages.clients, and translate namePrefix, lastFetchAt, lastFetchError and neverFetched into all 12 non-English locales. Cover the persistence path that had no test: the fetch-status writer over a real DB against a failing then a succeeding server, a cache hit writing nothing, and the negative-expiry rejection. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -6540,7 +6540,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.",
|
||||
"summary": "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.",
|
||||
"operationId": "post_panel_api_clients_email_externalLinks",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6558,19 +6558,36 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"externalLinks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"externalLinks"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"kind": "link",
|
||||
"value": "vless://uuid@host:443?...#srv",
|
||||
"remark": "DE"
|
||||
"remark": "DE",
|
||||
"enable": true,
|
||||
"expiryTime": 0
|
||||
},
|
||||
{
|
||||
"kind": "subscription",
|
||||
"value": "https://provider.example/sub/abc",
|
||||
"remark": "Provider"
|
||||
"remark": "Provider",
|
||||
"enable": false,
|
||||
"expiryTime": 1767225600000,
|
||||
"namePrefix": "[zjh] "
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ function extractPathParams(openApiPath) {
|
||||
|
||||
function mapType(t) {
|
||||
const v = String(t || '').toLowerCase();
|
||||
if (v.endsWith('[]')) return 'array';
|
||||
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
|
||||
if (v === 'float' || v === 'double') return 'number';
|
||||
if (v === 'boolean' || v === 'bool') return 'boolean';
|
||||
@@ -48,6 +49,15 @@ function mapType(t) {
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function schemaFromType(t) {
|
||||
const v = String(t || '').toLowerCase();
|
||||
if (v.endsWith('[]')) {
|
||||
const itemType = v.slice(0, -2);
|
||||
return { type: 'array', items: { type: mapType(itemType) } };
|
||||
}
|
||||
return { type: mapType(v) };
|
||||
}
|
||||
|
||||
function tryParseJson(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
try {
|
||||
@@ -63,7 +73,7 @@ function paramToOpenApi(p) {
|
||||
in: p.in,
|
||||
required: p.in === 'path' ? true : !p.optional,
|
||||
description: p.desc || '',
|
||||
schema: { type: mapType(p.type) },
|
||||
schema: schemaFromType(p.type),
|
||||
};
|
||||
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
|
||||
return out;
|
||||
@@ -109,7 +119,7 @@ function buildOperation(ep, tag) {
|
||||
const required = [];
|
||||
for (const bp of bodyParams) {
|
||||
properties[bp.name] = {
|
||||
type: mapType(bp.type),
|
||||
...schemaFromType(bp.type),
|
||||
description: bp.desc || '',
|
||||
};
|
||||
if (!bp.optional) required.push(bp.name);
|
||||
|
||||
@@ -35,7 +35,14 @@ import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||
import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
|
||||
|
||||
// One row sent to POST /clients/:email/externalLinks.
|
||||
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
|
||||
export type ExternalLinkInput = {
|
||||
kind: 'link' | 'subscription';
|
||||
value: string;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
namePrefix: string;
|
||||
};
|
||||
|
||||
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
|
||||
|
||||
|
||||
@@ -597,7 +597,7 @@ export const sections: readonly Section[] = [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -665,12 +665,12 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:email/externalLinks',
|
||||
summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
|
||||
summary: 'Replace a client\'s external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{ name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
|
||||
{ name: 'externalLinks', in: 'body', type: 'object[]', desc: 'Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.' },
|
||||
],
|
||||
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n ]\n}',
|
||||
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\n ]\n}',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
|
||||
import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
|
||||
|
||||
import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
@@ -50,6 +50,11 @@ interface ExternalLinkRow {
|
||||
kind: 'link' | 'subscription';
|
||||
value: string;
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
namePrefix: string;
|
||||
lastFetchAt: number;
|
||||
lastFetchError: string;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
@@ -157,6 +162,11 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
|
||||
kind: l.kind === 'subscription' ? 'subscription' : 'link',
|
||||
value: l.value || '',
|
||||
remark: l.remark || '',
|
||||
enable: l.enable !== false,
|
||||
expiryTime: Number(l.expiryTime) || 0,
|
||||
namePrefix: l.namePrefix || '',
|
||||
lastFetchAt: Number(l.lastFetchAt) || 0,
|
||||
lastFetchError: l.lastFetchError || '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -232,7 +242,16 @@ export default function ClientFormModal({
|
||||
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
||||
|
||||
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||
appendExternalLink({ kind, value: '', remark: '' });
|
||||
appendExternalLink({
|
||||
kind,
|
||||
value: '',
|
||||
remark: '',
|
||||
enable: true,
|
||||
expiryTime: 0,
|
||||
namePrefix: '',
|
||||
lastFetchAt: 0,
|
||||
lastFetchError: '',
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -622,7 +641,14 @@ reset: Number(values.reset) || 0,
|
||||
}
|
||||
|
||||
const externalLinks: ExternalLinkInput[] = values.externalLinks
|
||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
|
||||
.map((r) => ({
|
||||
kind: r.kind,
|
||||
value: r.value.trim(),
|
||||
remark: (r.remark || '').trim(),
|
||||
enable: r.enable !== false,
|
||||
expiryTime: Number(r.expiryTime) || 0,
|
||||
namePrefix: (r.namePrefix || '').trim(),
|
||||
}))
|
||||
.filter((r) => r.value !== '');
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -1043,24 +1069,40 @@ reset: Number(values.reset) || 0,
|
||||
{linkRows.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
|
||||
) : linkRows.map(({ field, index }) => (
|
||||
<div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<FormField name={`externalLinks.${index}.value`} noStyle>
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
<div key={field.id} className="external-link-card">
|
||||
<div className="external-link-row">
|
||||
<div className="external-link-enable">
|
||||
<FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
|
||||
<Switch size="small" />
|
||||
</FormField>
|
||||
<span>{t('enable')}</span>
|
||||
</div>
|
||||
<FormField name={`externalLinks.${index}.value`} noStyle>
|
||||
<Input
|
||||
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
/>
|
||||
</FormField>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="external-link-details two-cols">
|
||||
<FormField name={`externalLinks.${index}.remark`} noStyle>
|
||||
<Input aria-label={t('remark')} placeholder={t('remark')} />
|
||||
</FormField>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={`externalLinks.${index}.expiryTime`}
|
||||
render={({ field: expiryField }) => (
|
||||
<DateTimePicker
|
||||
value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={`externalLinks.${index}.remark`} noStyle>
|
||||
<Input
|
||||
style={{ width: 140 }}
|
||||
aria-label={t('remark')}
|
||||
placeholder={t('remark')}
|
||||
/>
|
||||
</FormField>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1072,17 +1114,50 @@ reset: Number(values.reset) || 0,
|
||||
{subscriptionRows.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
|
||||
) : subscriptionRows.map(({ field, index }) => (
|
||||
<div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<FormField name={`externalLinks.${index}.value`} noStyle>
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
aria-label="https://provider.example/sub/…"
|
||||
placeholder="https://provider.example/sub/…"
|
||||
<div key={field.id} className="external-link-card">
|
||||
<div className="external-link-row">
|
||||
<div className="external-link-enable">
|
||||
<FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
|
||||
<Switch size="small" />
|
||||
</FormField>
|
||||
<span>{t('enable')}</span>
|
||||
</div>
|
||||
<FormField name={`externalLinks.${index}.value`} noStyle>
|
||||
<Input
|
||||
aria-label="https://provider.example/sub/…"
|
||||
placeholder="https://provider.example/sub/…"
|
||||
/>
|
||||
</FormField>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="external-link-details three-cols">
|
||||
<FormField name={`externalLinks.${index}.remark`} noStyle>
|
||||
<Input aria-label={t('remark')} placeholder={t('remark')} />
|
||||
</FormField>
|
||||
<FormField name={`externalLinks.${index}.namePrefix`} noStyle>
|
||||
<Input aria-label={t('pages.clients.namePrefix')} placeholder={t('pages.clients.namePrefix')} />
|
||||
</FormField>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={`externalLinks.${index}.expiryTime`}
|
||||
render={({ field: expiryField }) => (
|
||||
<DateTimePicker
|
||||
value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Typography.Text type={field.lastFetchError ? 'danger' : 'secondary'} className="external-link-fetch-status">
|
||||
{field.lastFetchError
|
||||
? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}`
|
||||
: field.lastFetchAt > 0
|
||||
? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}`
|
||||
: t('pages.clients.neverFetched')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,76 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.external-link-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 6px;
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
.external-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.external-link-row .ant-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.external-link-enable {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 78px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.external-link-details {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.external-link-details.two-cols {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr);
|
||||
}
|
||||
|
||||
.external-link-details.three-cols {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, 0.8fr) minmax(220px, 0.8fr);
|
||||
}
|
||||
|
||||
.external-link-fetch-status {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.external-link-row {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.external-link-enable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.external-link-row .ant-input {
|
||||
flex-basis: calc(100% - 44px);
|
||||
}
|
||||
|
||||
.external-link-details.two-cols,
|
||||
.external-link-details.three-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -106,9 +106,15 @@ export const ClientPageResponseSchema = z.object({
|
||||
// A per-client external link surfaced in the client's subscription:
|
||||
// kind=link is a single share link, kind=subscription is a remote sub URL.
|
||||
export const ExternalLinkSchema = z.object({
|
||||
id: z.number().int().optional().default(0),
|
||||
kind: z.enum(['link', 'subscription']).default('link'),
|
||||
value: z.string(),
|
||||
remark: z.string().optional().default(''),
|
||||
enable: z.preprocess((v) => (v == null ? true : v), z.boolean()).default(true),
|
||||
expiryTime: z.number().int().optional().default(0),
|
||||
namePrefix: z.string().optional().default(''),
|
||||
lastFetchAt: z.number().int().optional().default(0),
|
||||
lastFetchError: z.string().optional().default(''),
|
||||
}).loose();
|
||||
|
||||
export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
|
||||
|
||||
Reference in New Issue
Block a user