mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 17:37:19 +00:00
Feature/fix external subscription client expiry (#6333)
* fix(sub): honor client expiry for external links * fix(ui): show client expiry on external links * fix(sub): address external expiry review
This commit is contained in:
@@ -56,8 +56,11 @@ _openapi:
|
|||||||
- depth: 2
|
- depth: 2
|
||||||
title: Replace a client's external links and external subscriptions. Sends the
|
title: Replace a client's external links and external subscriptions. Sends the
|
||||||
full set; the server replaces all rows. Disabled rows stay saved for
|
full set; the server replaces all rows. Disabled rows stay saved for
|
||||||
editing but are not emitted in generated subscriptions.
|
editing but are not emitted in generated subscriptions. The owning
|
||||||
url: '#replace-a-clients-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'
|
client's disabled or expired state also stops these rows from being
|
||||||
|
emitted on future subscription fetches; credentials already imported by
|
||||||
|
an app remain valid until the external provider revokes them.
|
||||||
|
url: '#replace-a-clients-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-the-owning-clients-disabled-or-expired-state-also-stops-these-rows-from-being-emitted-on-future-subscription-fetches-credentials-already-imported-by-an-app-remain-valid-until-the-external-provider-revokes-them'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Reset the up/down counters for every client globally. Quotas and expiry
|
title: Reset the up/down counters for every client globally. Quotas and expiry
|
||||||
are not affected. Triggers an Xray restart if any counter actually
|
are not affected. Triggers an Xray restart if any counter actually
|
||||||
@@ -318,8 +321,11 @@ _openapi:
|
|||||||
id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
|
id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
|
||||||
- content: Replace a client's external links and external subscriptions. Sends the
|
- content: Replace a client's external links and external subscriptions. Sends the
|
||||||
full set; the server replaces all rows. Disabled rows stay saved for
|
full set; the server replaces all rows. Disabled rows stay saved for
|
||||||
editing but are not emitted in generated subscriptions.
|
editing but are not emitted in generated subscriptions. The owning
|
||||||
id: replace-a-clients-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
|
client's disabled or expired state also stops these rows from being
|
||||||
|
emitted on future subscription fetches; credentials already imported
|
||||||
|
by an app remain valid until the external provider revokes them.
|
||||||
|
id: replace-a-clients-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-the-owning-clients-disabled-or-expired-state-also-stops-these-rows-from-being-emitted-on-future-subscription-fetches-credentials-already-imported-by-an-app-remain-valid-until-the-external-provider-revokes-them
|
||||||
- content: Reset the up/down counters for every client globally. Quotas and expiry
|
- content: Reset the up/down counters for every client globally. Quotas and expiry
|
||||||
are not affected. Triggers an Xray restart if any counter actually
|
are not affected. Triggers an Xray restart if any counter actually
|
||||||
moved.
|
moved.
|
||||||
|
|||||||
@@ -7290,7 +7290,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"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.",
|
"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. The owning client's disabled or expired state also stops these rows from being emitted on future subscription fetches; credentials already imported by an app remain valid until the external provider revokes them.",
|
||||||
"operationId": "post_panel_api_clients_email_externalLinks",
|
"operationId": "post_panel_api_clients_email_externalLinks",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -7315,7 +7315,7 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object"
|
"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."
|
"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 no link-specific expiry; the owning client's enabled state and expiry still apply. 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": [
|
"required": [
|
||||||
|
|||||||
@@ -7290,7 +7290,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"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.",
|
"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. The owning client's disabled or expired state also stops these rows from being emitted on future subscription fetches; credentials already imported by an app remain valid until the external provider revokes them.",
|
||||||
"operationId": "post_panel_api_clients_email_externalLinks",
|
"operationId": "post_panel_api_clients_email_externalLinks",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -7315,7 +7315,7 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object"
|
"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."
|
"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 no link-specific expiry; the owning client's enabled state and expiry still apply. 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": [
|
"required": [
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface DateTimePickerProps {
|
|||||||
format?: string;
|
format?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
allowClear?: boolean;
|
||||||
|
maxDate?: Dayjs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIGHT_THEME = {
|
const LIGHT_THEME = {
|
||||||
@@ -53,6 +55,8 @@ export default function DateTimePicker({
|
|||||||
format = 'YYYY-MM-DD HH:mm:ss',
|
format = 'YYYY-MM-DD HH:mm:ss',
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
allowClear = true,
|
||||||
|
maxDate,
|
||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
@@ -78,6 +82,14 @@ export default function DateTimePicker({
|
|||||||
return LIGHT_THEME;
|
return LIGHT_THEME;
|
||||||
}, [isDark, isUltra]);
|
}, [isDark, isUltra]);
|
||||||
|
|
||||||
|
const commitChange = (next: Dayjs | null) => {
|
||||||
|
if (next && maxDate && next.isAfter(maxDate)) {
|
||||||
|
if (datepicker === 'jalalian') setClearNonce((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
// The library hardcodes a Persian placeholder and exposes no working prop to
|
// 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
|
// 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
|
// the empty field shows no leftover Persian text. No dep array: re-apply
|
||||||
@@ -100,19 +112,20 @@ export default function DateTimePicker({
|
|||||||
onChange={(next: number | string | null) => {
|
onChange={(next: number | string | null) => {
|
||||||
if (suppressMountEmit.current) return;
|
if (suppressMountEmit.current) return;
|
||||||
if (next == null || next === '') {
|
if (next == null || next === '') {
|
||||||
onChange(null);
|
commitChange(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ms = typeof next === 'number' ? next : Number(next);
|
const ms = typeof next === 'number' ? next : Number(next);
|
||||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
if (Number.isFinite(ms)) commitChange(dayjs(ms));
|
||||||
}}
|
}}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
outputFormat="timestamp"
|
outputFormat="timestamp"
|
||||||
|
maxDate={maxDate?.toDate()}
|
||||||
persianNumbers
|
persianNumbers
|
||||||
rtlCalendar
|
rtlCalendar
|
||||||
theme={persianTheme}
|
theme={persianTheme}
|
||||||
/>
|
/>
|
||||||
{value && !disabled && (
|
{value && allowClear && !disabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="jdp-clear"
|
className="jdp-clear"
|
||||||
@@ -120,7 +133,7 @@ export default function DateTimePicker({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onChange(null);
|
commitChange(null);
|
||||||
setClearNonce((n) => n + 1);
|
setClearNonce((n) => n + 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -134,13 +147,15 @@ export default function DateTimePicker({
|
|||||||
return (
|
return (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(next) => onChange(next || null)}
|
onChange={(next) => commitChange(next || null)}
|
||||||
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
|
onCalendarChange={(next) => commitChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||||
needConfirm={false}
|
needConfirm={false}
|
||||||
format={format}
|
format={format}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
allowClear={allowClear}
|
||||||
|
maxDate={maxDate}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function resolveExternalLinkExpiry(
|
||||||
|
externalExpiry: number | null | undefined,
|
||||||
|
clientExpiry: number | null | undefined,
|
||||||
|
): number {
|
||||||
|
const explicitExpiry = Number(externalExpiry) || 0;
|
||||||
|
if (explicitExpiry > 0) return explicitExpiry;
|
||||||
|
|
||||||
|
const inheritedExpiry = Number(clientExpiry) || 0;
|
||||||
|
return inheritedExpiry > 0 ? inheritedExpiry : 0;
|
||||||
|
}
|
||||||
@@ -1118,14 +1118,14 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/:email/externalLinks',
|
path: '/panel/api/clients/:email/externalLinks',
|
||||||
summary:
|
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.",
|
"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. The owning client's disabled or expired state also stops these rows from being emitted on future subscription fetches; credentials already imported by an app remain valid until the external provider revokes them.",
|
||||||
params: [
|
params: [
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
{
|
{
|
||||||
name: 'externalLinks',
|
name: 'externalLinks',
|
||||||
in: 'body',
|
in: 'body',
|
||||||
type: 'object[]',
|
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.',
|
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 no link-specific expiry; the owning client's enabled state and expiry still apply. 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", "enable": true, "expiryTime": 0 },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\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}',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { HttpUtil, IntlUtil, RandomUtil, Wireguard } from '@/utils';
|
|||||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
|
import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
|
||||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||||
|
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||||
@@ -1370,17 +1371,22 @@ export default function ClientFormModal({
|
|||||||
<Controller
|
<Controller
|
||||||
control={methods.control}
|
control={methods.control}
|
||||||
name={`externalLinks.${index}.expiryTime`}
|
name={`externalLinks.${index}.expiryTime`}
|
||||||
render={({ field: expiryField }) => (
|
render={({ field: expiryField }) => {
|
||||||
<DateTimePicker
|
const displayedExpiry = resolveExternalLinkExpiry(
|
||||||
value={
|
expiryField.value,
|
||||||
Number(expiryField.value) > 0
|
expiryDate,
|
||||||
? dayjs(Number(expiryField.value))
|
);
|
||||||
: null
|
const hasSpecificExpiry = Number(expiryField.value) > 0;
|
||||||
}
|
return (
|
||||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
<DateTimePicker
|
||||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
|
||||||
/>
|
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||||
)}
|
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||||
|
allowClear={hasSpecificExpiry}
|
||||||
|
maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1442,17 +1448,22 @@ export default function ClientFormModal({
|
|||||||
<Controller
|
<Controller
|
||||||
control={methods.control}
|
control={methods.control}
|
||||||
name={`externalLinks.${index}.expiryTime`}
|
name={`externalLinks.${index}.expiryTime`}
|
||||||
render={({ field: expiryField }) => (
|
render={({ field: expiryField }) => {
|
||||||
<DateTimePicker
|
const displayedExpiry = resolveExternalLinkExpiry(
|
||||||
value={
|
expiryField.value,
|
||||||
Number(expiryField.value) > 0
|
expiryDate,
|
||||||
? dayjs(Number(expiryField.value))
|
);
|
||||||
: null
|
const hasSpecificExpiry = Number(expiryField.value) > 0;
|
||||||
}
|
return (
|
||||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
<DateTimePicker
|
||||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
|
||||||
/>
|
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||||
)}
|
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||||
|
allowClear={hasSpecificExpiry}
|
||||||
|
maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||||
|
|
||||||
|
describe('resolveExternalLinkExpiry', () => {
|
||||||
|
it('uses the client expiry when the external link has no specific expiry', () => {
|
||||||
|
expect(resolveExternalLinkExpiry(0, 1_800_000_000_000)).toBe(1_800_000_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an explicit external-link expiry', () => {
|
||||||
|
expect(resolveExternalLinkExpiry(1_700_000_000_000, 1_800_000_000_000)).toBe(1_700_000_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays empty when neither expiry is set', () => {
|
||||||
|
expect(resolveExternalLinkExpiry(0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
import { fireEvent } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import DateTimePicker from '@/components/form/DateTimePicker';
|
import DateTimePicker from '@/components/form/DateTimePicker';
|
||||||
|
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||||
import { renderWithProviders } from './test-utils';
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
vi.mock('persian-calendar-suite', () => ({
|
||||||
|
PersianDateTimePicker: ({
|
||||||
|
maxDate,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
maxDate?: Date;
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Persian date time picker"
|
||||||
|
data-testid="persian-date-time-picker"
|
||||||
|
data-max-date={maxDate?.toISOString()}
|
||||||
|
onClick={() => onChange?.((maxDate?.getTime() ?? 0) + 1)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => setDatepicker('gregorian'));
|
||||||
|
|
||||||
function openPicker(): void {
|
function openPicker(): void {
|
||||||
const input = document.querySelector('.ant-picker input');
|
const input = document.querySelector('.ant-picker input');
|
||||||
if (!input) throw new Error('picker input not rendered');
|
if (!input) throw new Error('picker input not rendered');
|
||||||
@@ -40,4 +61,34 @@ describe('DateTimePicker', () => {
|
|||||||
|
|
||||||
expect(document.querySelector('.ant-picker-ok')).toBeNull();
|
expect(document.querySelector('.ant-picker-ok')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides the Gregorian clear control and disables dates after maxDate', () => {
|
||||||
|
const maxDate = dayjs().add(1, 'day').startOf('day');
|
||||||
|
renderWithProviders(
|
||||||
|
<DateTimePicker value={maxDate} onChange={vi.fn()} allowClear={false} maxDate={maxDate} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector('.ant-picker-clear')).toBeNull();
|
||||||
|
openPicker();
|
||||||
|
const blockedCell = document.querySelector(
|
||||||
|
`.ant-picker-cell[title="${maxDate.add(1, 'day').format('YYYY-MM-DD')}"]`,
|
||||||
|
);
|
||||||
|
expect(blockedCell?.classList.contains('ant-picker-cell-disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies clear and max-date constraints to the Jalali picker', () => {
|
||||||
|
setDatepicker('jalalian');
|
||||||
|
const maxDate = dayjs('2030-01-02T03:04:05');
|
||||||
|
const onChange = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<DateTimePicker value={maxDate} onChange={onChange} allowClear={false} maxDate={maxDate} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector('.jdp-clear')).toBeNull();
|
||||||
|
expect(screen.getByTestId('persian-date-time-picker').dataset.maxDate).toBe(
|
||||||
|
maxDate.toDate().toISOString(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('persian-date-time-picker'));
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
var proxies []map[string]any
|
var proxies []map[string]any
|
||||||
|
var hasInactiveExternal bool
|
||||||
|
|
||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
for _, inbound := range inbounds {
|
for _, inbound := range inbounds {
|
||||||
@@ -56,6 +57,11 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, ext := range externalLinks {
|
for _, ext := range externalLinks {
|
||||||
|
if !ext.Active {
|
||||||
|
seenEmails[ext.Email] = struct{}{}
|
||||||
|
hasInactiveExternal = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, el := range expandEntry(ext) {
|
for _, el := range expandEntry(ext) {
|
||||||
name := el.Name
|
name := el.Name
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -68,17 +74,21 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(proxies) == 0 {
|
if len(proxies) == 0 && !hasInactiveExternal {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureUniqueProxyNames(proxies)
|
|
||||||
|
|
||||||
emails := make([]string, 0, len(seenEmails))
|
emails := make([]string, 0, len(seenEmails))
|
||||||
for e := range seenEmails {
|
for e := range seenEmails {
|
||||||
emails = append(emails, e)
|
emails = append(emails, e)
|
||||||
}
|
}
|
||||||
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
||||||
|
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||||
|
if len(proxies) == 0 {
|
||||||
|
return "", header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureUniqueProxyNames(proxies)
|
||||||
|
|
||||||
proxyNames := make([]string, 0, len(proxies)+1)
|
proxyNames := make([]string, 0, len(proxies)+1)
|
||||||
for _, proxy := range proxies {
|
for _, proxy := range proxies {
|
||||||
@@ -116,7 +126,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
|
||||||
return string(finalYAML), header, nil
|
return string(finalYAML), header, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
|
|||||||
subReq := a.subService.ForRequest(host)
|
subReq := a.subService.ForRequest(host)
|
||||||
subReq.subscriptionBody = false
|
subReq.subscriptionBody = false
|
||||||
subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
|
subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || subs == nil {
|
||||||
writeSubError(c, err)
|
writeSubError(c, err)
|
||||||
return PageData{}, false
|
return PageData{}, false
|
||||||
}
|
}
|
||||||
@@ -413,7 +413,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
subReq := a.subService.ForRequest(host)
|
subReq := a.subService.ForRequest(host)
|
||||||
subReq.subscriptionBody = true
|
subReq.subscriptionBody = true
|
||||||
subs, _, _, traffic, err := subReq.getSubs(subId)
|
subs, _, _, traffic, err := subReq.getSubs(subId)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || subs == nil {
|
||||||
writeSubError(c, err)
|
writeSubError(c, err)
|
||||||
} else {
|
} else {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
@@ -742,7 +742,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
|||||||
writeSubError(c, err)
|
writeSubError(c, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if len(jsonSub) == 0 {
|
if len(jsonSub) == 0 && header == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||||
@@ -793,7 +793,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
|||||||
writeSubError(c, err)
|
writeSubError(c, err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if len(clashSub) == 0 {
|
if len(clashSub) == 0 && header == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||||
)
|
)
|
||||||
|
|
||||||
// externalLinkEntry is one client × external-link row, resolved for a
|
// externalLinkEntry is one client × external-link row resolved for a request.
|
||||||
// subscription request. Email/Enable come from the owning client.
|
// Active applies the owning client's enabled and expiry state.
|
||||||
type externalLinkEntry struct {
|
type externalLinkEntry struct {
|
||||||
Kind string
|
Kind string
|
||||||
Value string
|
Value string
|
||||||
@@ -23,6 +23,7 @@ type externalLinkEntry struct {
|
|||||||
NamePrefix string
|
NamePrefix string
|
||||||
Email string
|
Email string
|
||||||
Enable bool
|
Enable bool
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// expandedLink is a single share link contributed by an entry, with the display
|
// expandedLink is a single share link contributed by an entry, with the display
|
||||||
@@ -32,9 +33,8 @@ type expandedLink struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientExternalLinksBySubId returns every external-link row attached to a
|
// getClientExternalLinksBySubId returns active rows with owner state attached.
|
||||||
// client that carries the given subId, in stable order. Stays inside
|
// Consumers keep inactive owners as metadata but omit their link values.
|
||||||
// internal/sub + database + util/link — no dependency on the panel service layer.
|
|
||||||
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
|
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var recs []model.ClientRecord
|
var recs []model.ClientRecord
|
||||||
@@ -74,6 +74,7 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
|
|||||||
NamePrefix: r.NamePrefix,
|
NamePrefix: r.NamePrefix,
|
||||||
Email: rec.Email,
|
Email: rec.Email,
|
||||||
Enable: rec.Enable,
|
Enable: rec.Enable,
|
||||||
|
Active: rec.Enable && (rec.ExpiryTime <= 0 || rec.ExpiryTime > now),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedInactiveExternalOnlySub(t *testing.T, subID, email string, enabled bool, expiry int64) {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
rec := &model.ClientRecord{Email: email, SubID: subID, UUID: subID + "-uuid", Enable: true, ExpiryTime: expiry}
|
||||||
|
if err := db.Create(rec).Error; err != nil {
|
||||||
|
t.Fatalf("seed client: %v", err)
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
if err := db.Model(rec).Update("enable", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable client: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{Email: email, Up: 11, Down: 22, Total: 1024, ExpiryTime: expiry}).Error; err != nil {
|
||||||
|
t.Fatalf("seed traffic: %v", err)
|
||||||
|
}
|
||||||
|
link := "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#external"
|
||||||
|
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: link, SortIndex: 1}).Error; err != nil {
|
||||||
|
t.Fatalf("seed external link: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
states := []struct {
|
||||||
|
name string
|
||||||
|
enabled bool
|
||||||
|
expiry int64
|
||||||
|
}{
|
||||||
|
{name: "disabled", enabled: false, expiry: time.Now().Add(time.Hour).UnixMilli()},
|
||||||
|
{name: "expired", enabled: true, expiry: time.Now().Add(-time.Hour).UnixMilli()},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, state := range states {
|
||||||
|
t.Run(state.name, func(t *testing.T) {
|
||||||
|
initSubDB(t)
|
||||||
|
subID := "external-" + state.name
|
||||||
|
email := state.name + "@example.com"
|
||||||
|
seedInactiveExternalOnlySub(t, subID, email, state.enabled, state.expiry)
|
||||||
|
|
||||||
|
oldDistFS := distFS
|
||||||
|
distFS = testDistFS
|
||||||
|
t.Cleanup(func() { distFS = oldDistFS })
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
NewSUBController(
|
||||||
|
router.Group("/"),
|
||||||
|
WithSUBJsonEnabled(true),
|
||||||
|
WithSUBClashEnabled(true),
|
||||||
|
WithSUBEncryption(false),
|
||||||
|
)
|
||||||
|
|
||||||
|
wantHeader := fmt.Sprintf("upload=11; download=22; total=1024; expire=%d", state.expiry/1000)
|
||||||
|
for _, path := range []string{"/sub/" + subID, "/json/" + subID + "?view=raw", "/clash/" + subID + "?view=raw"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("last_sub_fetch", 0).Error; err != nil {
|
||||||
|
t.Fatalf("reset last_sub_fetch: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.Host = "sub.example.com"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w.Body.Len() != 0 {
|
||||||
|
t.Fatalf("inactive external link leaked in body: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Subscription-Userinfo"); got != wantHeader {
|
||||||
|
t.Fatalf("Subscription-Userinfo = %q, want %q", got, wantHeader)
|
||||||
|
}
|
||||||
|
var traffic xray.ClientTraffic
|
||||||
|
if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil {
|
||||||
|
t.Fatalf("load traffic: %v", err)
|
||||||
|
}
|
||||||
|
if traffic.LastSubFetch == 0 {
|
||||||
|
t.Fatal("successful empty response did not update last_sub_fetch")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sub/"+subID, nil)
|
||||||
|
req.Host = "sub.example.com"
|
||||||
|
req.Header.Set("Accept", "text/html")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("HTML status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "11111111-1111-1111-1111-111111111111") {
|
||||||
|
t.Fatalf("HTML page exposed inactive external link: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `"links":[]`) {
|
||||||
|
t.Fatalf("HTML page did not render an empty links list: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
var header string
|
var header string
|
||||||
|
var hasInactiveExternal bool
|
||||||
|
|
||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
entries := make([]subConfigEntry, 0, len(inbounds))
|
entries := make([]subConfigEntry, 0, len(inbounds))
|
||||||
@@ -127,6 +128,11 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
|||||||
configArray = append(configArray, entry.configs...)
|
configArray = append(configArray, entry.configs...)
|
||||||
}
|
}
|
||||||
for _, ext := range externalLinks {
|
for _, ext := range externalLinks {
|
||||||
|
if !ext.Active {
|
||||||
|
seenEmails[ext.Email] = struct{}{}
|
||||||
|
hasInactiveExternal = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, el := range expandEntry(ext) {
|
for _, el := range expandEntry(ext) {
|
||||||
outbound := parsedExternalOutbound(el.Link)
|
outbound := parsedExternalOutbound(el.Link)
|
||||||
if outbound == nil {
|
if outbound == nil {
|
||||||
@@ -148,7 +154,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(configArray) == 0 {
|
if len(configArray) == 0 && !hasInactiveExternal {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +163,10 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
|||||||
emails = append(emails, e)
|
emails = append(emails, e)
|
||||||
}
|
}
|
||||||
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
||||||
|
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||||
|
if len(configArray) == 0 {
|
||||||
|
return "", header, nil
|
||||||
|
}
|
||||||
|
|
||||||
var finalJson []byte
|
var finalJson []byte
|
||||||
if len(configArray) == 1 && !alwaysReturnArray {
|
if len(configArray) == 1 && !alwaysReturnArray {
|
||||||
@@ -165,7 +175,6 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
|||||||
finalJson, _ = json.MarshalIndent(configArray, "", " ")
|
finalJson, _ = json.MarshalIndent(configArray, "", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
|
||||||
return string(finalJson), header, nil
|
return string(finalJson), header, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
|||||||
|
|
||||||
// A client with two link rows: ordering by sort_index and email/enable
|
// A client with two link rows: ordering by sort_index and email/enable
|
||||||
// attribution from the owning client (the loop copies rec.Email/rec.Enable).
|
// attribution from the owning client (the loop copies rec.Email/rec.Enable).
|
||||||
rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true}
|
rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}
|
||||||
if err := db.Create(rec).Error; err != nil {
|
if err := db.Create(rec).Error; err != nil {
|
||||||
t.Fatalf("seed client: %v", err)
|
t.Fatalf("seed client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -331,10 +331,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
|||||||
if out[0].Email != "owner@x" || out[0].Enable != true {
|
if out[0].Email != "owner@x" || out[0].Enable != true {
|
||||||
t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
|
t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
|
||||||
}
|
}
|
||||||
|
if !out[0].Active {
|
||||||
|
t.Fatal("active owner marked inactive")
|
||||||
|
}
|
||||||
|
|
||||||
// A DISABLED client must produce entries with Enable=false, proving the
|
// A disabled owner stays visible as metadata but cannot expose its link.
|
||||||
// value is read from the client row (Enable has a gorm default:true, so
|
// Enable has a gorm default:true, so update it after insertion.
|
||||||
// flip it with a raw UPDATE that bypasses the default).
|
|
||||||
dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
|
dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
|
||||||
if err := db.Create(dis).Error; err != nil {
|
if err := db.Create(dis).Error; err != nil {
|
||||||
t.Fatalf("seed disabled client: %v", err)
|
t.Fatalf("seed disabled client: %v", err)
|
||||||
@@ -352,8 +354,26 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
|||||||
if len(offOut) != 1 {
|
if len(offOut) != 1 {
|
||||||
t.Fatalf("disabled client entries = %d, want 1", len(offOut))
|
t.Fatalf("disabled client entries = %d, want 1", len(offOut))
|
||||||
}
|
}
|
||||||
if offOut[0].Email != "off@x" || offOut[0].Enable != false {
|
if offOut[0].Enable || offOut[0].Active {
|
||||||
t.Fatalf("disabled attribution wrong: email=%q enable=%v", offOut[0].Email, offOut[0].Enable)
|
t.Fatalf("disabled owner state = enable:%v active:%v", offOut[0].Enable, offOut[0].Active)
|
||||||
|
}
|
||||||
|
|
||||||
|
expired := &model.ClientRecord{Email: "expired@x", SubID: "sub-expired", UUID: "u4", Enable: true, ExpiryTime: time.Now().Add(-time.Hour).UnixMilli()}
|
||||||
|
if err := db.Create(expired).Error; err != nil {
|
||||||
|
t.Fatalf("seed expired client: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.ClientExternalLink{ClientId: expired.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://d", SortIndex: 1}).Error; err != nil {
|
||||||
|
t.Fatalf("seed expired client link: %v", err)
|
||||||
|
}
|
||||||
|
expiredOut, err := s.getClientExternalLinksBySubId("sub-expired")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expired subId err = %v", err)
|
||||||
|
}
|
||||||
|
if len(expiredOut) != 1 {
|
||||||
|
t.Fatalf("expired client entries = %d, want 1", len(expiredOut))
|
||||||
|
}
|
||||||
|
if !expiredOut[0].Enable || expiredOut[0].Active {
|
||||||
|
t.Fatalf("expired owner state = enable:%v active:%v", expiredOut[0].Enable, expiredOut[0].Active)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,13 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
|||||||
if ext.Enable {
|
if ext.Enable {
|
||||||
hasEnabledClient = true
|
hasEnabledClient = true
|
||||||
}
|
}
|
||||||
|
if !ext.Active {
|
||||||
|
seenEmails[ext.Email] = struct{}{}
|
||||||
|
if result == nil {
|
||||||
|
result = []string{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, el := range expandEntry(ext) {
|
for _, el := range expandEntry(ext) {
|
||||||
if link := applyRemarkToLink(el.Link, el.Name); link != "" {
|
if link := applyRemarkToLink(el.Link, el.Name); link != "" {
|
||||||
result = append(result, link)
|
result = append(result, link)
|
||||||
|
|||||||
Reference in New Issue
Block a user