mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
feat(sub): per-client external links and remote subscriptions
Add a Links tab to the client form for attaching third-party share links and remote subscription URLs per client. They are merged into the client's raw/JSON/Clash subscription output: links are emitted verbatim and parsed for JSON/Clash; subscription URLs are fetched (cached, with a short timeout) and their configs merged in. i18n keys added across all 13 locales.
This commit is contained in:
@@ -4390,7 +4390,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Fetch one client by email, including the inbound IDs it is attached to.",
|
||||
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
|
||||
"operationId": "get_panel_api_clients_get_email",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4719,6 +4719,74 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/{email}/externalLinks": {
|
||||
"post": {
|
||||
"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.",
|
||||
"operationId": "post_panel_api_clients_email_externalLinks",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"kind": "link",
|
||||
"value": "vless://uuid@host:443?...#srv",
|
||||
"remark": "DE"
|
||||
},
|
||||
{
|
||||
"kind": "subscription",
|
||||
"value": "https://provider.example/sub/abc",
|
||||
"remark": "Provider"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/resetAllTraffics": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'antd';
|
||||
|
||||
interface Option {
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface SelectAllClearButtonsProps {
|
||||
options: Option[];
|
||||
value: number[];
|
||||
onChange: (value: number[]) => void;
|
||||
interface SelectAllClearButtonsProps<T extends string | number = number> {
|
||||
options: Array<{ value: T }>;
|
||||
value: T[];
|
||||
onChange: (value: T[]) => void;
|
||||
/** Override the default "Select all" label (defaults to the inbound copy). */
|
||||
selectAllLabel?: string;
|
||||
/** Override the default "Clear all" label (defaults to the inbound copy). */
|
||||
clearLabel?: string;
|
||||
}
|
||||
|
||||
export default function SelectAllClearButtons({
|
||||
export default function SelectAllClearButtons<T extends string | number = number>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
selectAllLabel,
|
||||
clearLabel,
|
||||
}: SelectAllClearButtonsProps) {
|
||||
}: SelectAllClearButtonsProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const optionValues = options.map((o) => o.value);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ClientsSummary,
|
||||
type ClientPageResponse,
|
||||
type InboundOption,
|
||||
type ExternalLink,
|
||||
type BulkAdjustResult,
|
||||
type BulkAttachResult,
|
||||
type BulkCreateResult,
|
||||
@@ -30,7 +31,10 @@ import {
|
||||
} from '@/schemas/client';
|
||||
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||
|
||||
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
|
||||
// One row sent to POST /clients/:email/externalLinks.
|
||||
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
|
||||
|
||||
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||
|
||||
@@ -350,6 +354,12 @@ export function useClients() {
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const setExternalLinksMut = useMutation({
|
||||
mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
|
||||
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
const bulkAttachMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
|
||||
@@ -364,6 +374,7 @@ export function useClients() {
|
||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||
});
|
||||
|
||||
|
||||
const bulkDetachMut = useMutation({
|
||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
|
||||
@@ -424,6 +435,10 @@ export function useClients() {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return attachMut.mutateAsync({ email, inboundIds });
|
||||
}, [attachMut]);
|
||||
const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
return setExternalLinksMut.mutateAsync({ email, externalLinks });
|
||||
}, [setExternalLinksMut]);
|
||||
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||
@@ -553,6 +568,7 @@ export function useClients() {
|
||||
bulkAddToGroup,
|
||||
bulkRemoveFromGroup,
|
||||
attach,
|
||||
setExternalLinks,
|
||||
bulkAttach,
|
||||
detach,
|
||||
bulkDetach,
|
||||
|
||||
@@ -503,12 +503,12 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get/:email',
|
||||
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
|
||||
summary: 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
|
||||
params: [
|
||||
{ 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 }\n}',
|
||||
'{\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}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -563,6 +563,17 @@ export const sections: readonly Section[] = [
|
||||
body: '{\n "inboundIds": [5]\n}',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
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.' },
|
||||
],
|
||||
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}',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/resetAllTraffics',
|
||||
|
||||
@@ -16,16 +16,17 @@ import {
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||
import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
@@ -38,6 +39,13 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||
const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
|
||||
|
||||
// One editable row in the Links tab. `key` is a stable client-side id for React.
|
||||
interface ExternalLinkRow {
|
||||
key: number;
|
||||
kind: 'link' | 'subscription';
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
@@ -51,10 +59,13 @@ interface SaveMetaEdit {
|
||||
email: string;
|
||||
attach: number[];
|
||||
detach: number[];
|
||||
externalLinks: ExternalLinkInput[];
|
||||
}
|
||||
|
||||
interface SaveMetaCreate {
|
||||
isEdit: false;
|
||||
email: string;
|
||||
externalLinks: ExternalLinkInput[];
|
||||
}
|
||||
|
||||
interface SaveCreatePayload {
|
||||
@@ -67,6 +78,7 @@ interface ClientFormModalProps {
|
||||
mode: Mode;
|
||||
client: ClientRecord | null;
|
||||
inbounds: InboundOption[];
|
||||
attachedExternalLinks?: ExternalLink[];
|
||||
attachedIds?: number[];
|
||||
tgBotEnable?: boolean;
|
||||
groups?: string[];
|
||||
@@ -98,6 +110,7 @@ interface FormState {
|
||||
comment: string;
|
||||
enable: boolean;
|
||||
inboundIds: number[];
|
||||
externalLinks: ExternalLinkRow[];
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
@@ -121,9 +134,19 @@ function emptyForm(): FormState {
|
||||
comment: '',
|
||||
enable: true,
|
||||
inboundIds: [],
|
||||
externalLinks: [],
|
||||
};
|
||||
}
|
||||
|
||||
let externalLinkRowSeq = 0;
|
||||
function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
|
||||
return (links || []).map((l) => ({
|
||||
key: (externalLinkRowSeq += 1),
|
||||
kind: l.kind === 'subscription' ? 'subscription' : 'link',
|
||||
value: l.value || '',
|
||||
}));
|
||||
}
|
||||
|
||||
function bytesToGB(bytes: number): number {
|
||||
if (!bytes || bytes <= 0) return 0;
|
||||
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||
@@ -139,6 +162,7 @@ export default function ClientFormModal({
|
||||
mode,
|
||||
client,
|
||||
inbounds,
|
||||
attachedExternalLinks = [],
|
||||
attachedIds = [],
|
||||
tgBotEnable = false,
|
||||
groups = [],
|
||||
@@ -162,6 +186,27 @@ export default function ClientFormModal({
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
|
||||
}));
|
||||
}
|
||||
|
||||
function updateExternalLinkRow(key: number, value: string) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
|
||||
}));
|
||||
}
|
||||
|
||||
function removeExternalLinkRow(key: number) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
externalLinks: prev.externalLinks.filter((r) => r.key !== key),
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setIpsModalOpen(false);
|
||||
@@ -186,6 +231,7 @@ export default function ClientFormModal({
|
||||
comment: client.comment || '',
|
||||
enable: !!client.enable,
|
||||
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
|
||||
externalLinks: toExternalLinkRows(attachedExternalLinks),
|
||||
};
|
||||
if (et < 0) {
|
||||
next.delayedStart = true;
|
||||
@@ -300,6 +346,9 @@ export default function ClientFormModal({
|
||||
[inbounds],
|
||||
);
|
||||
|
||||
const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
|
||||
const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
|
||||
|
||||
async function loadIps() {
|
||||
if (!isEdit || !client?.email) return;
|
||||
setIpsLoading(true);
|
||||
@@ -400,6 +449,10 @@ export default function ClientFormModal({
|
||||
clientPayload.reverse = { tag: reverseTag };
|
||||
}
|
||||
|
||||
const externalLinks: ExternalLinkInput[] = form.externalLinks
|
||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
||||
.filter((r) => r.value !== '');
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let msg;
|
||||
@@ -413,11 +466,12 @@ export default function ClientFormModal({
|
||||
email: client.email,
|
||||
attach: toAttach,
|
||||
detach: toDetach,
|
||||
externalLinks,
|
||||
});
|
||||
} else {
|
||||
msg = await save(
|
||||
{ client: clientPayload, inboundIds: form.inboundIds },
|
||||
{ isEdit: false },
|
||||
{ isEdit: false, email: clientPayload.email as string, externalLinks },
|
||||
);
|
||||
}
|
||||
if (msg?.success) close();
|
||||
@@ -692,6 +746,57 @@ export default function ClientFormModal({
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'links',
|
||||
label: t('pages.clients.tabLinks'),
|
||||
children: (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||
{t('pages.clients.linksHint')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
|
||||
{t('pages.clients.addExternalLink')}
|
||||
</Button>
|
||||
<div style={{ marginTop: 12, marginBottom: 24 }}>
|
||||
{linkRows.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
|
||||
) : linkRows.map((row) => (
|
||||
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
|
||||
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
/>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
|
||||
{t('pages.clients.addExternalSubscription')}
|
||||
</Button>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{subscriptionRows.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
|
||||
) : subscriptionRows.map((row) => (
|
||||
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
|
||||
placeholder="https://provider.example/sub/…"
|
||||
/>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
@@ -53,7 +53,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { IntlUtil, SizeFormatter } from '@/utils';
|
||||
@@ -199,7 +199,7 @@ export default function ClientsPage() {
|
||||
setQuery,
|
||||
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
|
||||
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
||||
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
||||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||||
applyTrafficEvent, applyClientStatsEvent,
|
||||
refresh,
|
||||
@@ -220,6 +220,7 @@ export default function ClientsPage() {
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||||
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
||||
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
@@ -429,6 +430,7 @@ export default function ClientsPage() {
|
||||
setFormMode('add');
|
||||
setEditingClient(null);
|
||||
setEditingAttachedIds([]);
|
||||
setEditingExternalLinks([]);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
@@ -441,6 +443,7 @@ export default function ClientsPage() {
|
||||
setEditingClient(merged);
|
||||
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
|
||||
setEditingAttachedIds([...ids]);
|
||||
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
@@ -567,10 +570,18 @@ export default function ClientsPage() {
|
||||
|
||||
const onSave = useCallback(async (
|
||||
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
|
||||
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
|
||||
meta:
|
||||
| { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
|
||||
| { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
|
||||
) => {
|
||||
if (!meta.isEdit) {
|
||||
return create(payload);
|
||||
const createMsg = await create(payload);
|
||||
if (!createMsg?.success) return createMsg;
|
||||
if (meta.email && meta.externalLinks.length > 0) {
|
||||
const r = await setExternalLinks(meta.email, meta.externalLinks);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
return createMsg;
|
||||
}
|
||||
const updateMsg = await update(meta.email, payload);
|
||||
if (!updateMsg?.success) return updateMsg;
|
||||
@@ -582,8 +593,11 @@ export default function ClientsPage() {
|
||||
const r = await detach(meta.email, meta.detach);
|
||||
if (!r?.success) return r;
|
||||
}
|
||||
// Always replace the client's external links (an empty set clears them).
|
||||
const r = await setExternalLinks(meta.email, meta.externalLinks);
|
||||
if (!r?.success) return r;
|
||||
return updateMsg;
|
||||
}, [create, update, attach, detach]);
|
||||
}, [create, update, attach, detach, setExternalLinks]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['clients-page'];
|
||||
@@ -1243,6 +1257,7 @@ export default function ClientsPage() {
|
||||
mode={formMode}
|
||||
client={editingClient}
|
||||
attachedIds={editingAttachedIds}
|
||||
attachedExternalLinks={editingExternalLinks}
|
||||
inbounds={inbounds}
|
||||
tgBotEnable={tgBotEnable}
|
||||
groups={allGroups}
|
||||
|
||||
@@ -71,9 +71,20 @@ export const ClientPageResponseSchema = z.object({
|
||||
groups: nullableStringArray.optional(),
|
||||
});
|
||||
|
||||
// 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({
|
||||
kind: z.enum(['link', 'subscription']).default('link'),
|
||||
value: z.string(),
|
||||
remark: z.string().optional().default(''),
|
||||
}).loose();
|
||||
|
||||
export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
|
||||
|
||||
export const ClientHydrateSchema = z.object({
|
||||
client: ClientRecordSchema,
|
||||
inboundIds: nullableNumberArray,
|
||||
externalLinks: ExternalLinkListSchema.optional(),
|
||||
});
|
||||
|
||||
export const BulkAdjustResultSchema = z.object({
|
||||
@@ -203,6 +214,7 @@ export const ClientBulkAddFormSchema = z.object({
|
||||
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
|
||||
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
|
||||
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
||||
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
|
||||
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
|
||||
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
|
||||
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
|
||||
|
||||
@@ -69,6 +69,7 @@ func initModels() error {
|
||||
&model.ApiToken{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.ClientExternalLink{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
|
||||
@@ -47,6 +47,7 @@ func migrationModels() []any {
|
||||
&model.InboundClientIps{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.ClientExternalLink{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
|
||||
@@ -629,6 +629,31 @@ type ClientInbound struct {
|
||||
|
||||
func (ClientInbound) TableName() string { return "client_inbounds" }
|
||||
|
||||
// ClientExternalLink is a per-client entry surfaced in the client's
|
||||
// subscription. Two kinds:
|
||||
// - "link": a single third-party share link (vless://, vmess://, trojan://,
|
||||
// ss://, hysteria2://, wireguard://). Emitted verbatim in raw subs; parsed
|
||||
// into an outbound/proxy for JSON and Clash.
|
||||
// - "subscription": a remote subscription URL. The panel fetches it (cached),
|
||||
// decodes its links, and merges them into the client's subscription.
|
||||
type ClientExternalLink struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ClientId int `json:"clientId" gorm:"index;column:client_id"`
|
||||
Kind string `json:"kind" gorm:"column:kind"`
|
||||
Value string `json:"value" gorm:"column:value"`
|
||||
Remark string `json:"remark" gorm:"column:remark"`
|
||||
SortIndex int `json:"sortIndex" gorm:"column:sort_index"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientExternalLink) TableName() string { return "client_external_links" }
|
||||
|
||||
// External link kinds.
|
||||
const (
|
||||
ExternalLinkKindLink = "link"
|
||||
ExternalLinkKindSubscription = "subscription"
|
||||
)
|
||||
|
||||
type InboundFallback struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// clashProxyFromExternal parses a pasted share link and converts it into a
|
||||
// mihomo/Clash proxy entry named `name`. Returns nil for links Clash can't
|
||||
// represent (the entry is then skipped, mirroring how getProxies drops
|
||||
// unsupported inbound protocols). vmess/vless/trojan reuse the existing
|
||||
// applyTransport/applySecurity helpers; ss/hysteria2/wireguard map directly.
|
||||
func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[string]any {
|
||||
ob := parseExternalLink(rawLink)
|
||||
if ob == nil {
|
||||
return nil
|
||||
}
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
settings, _ := ob["settings"].(map[string]any)
|
||||
stream, _ := ob["streamSettings"].(map[string]any)
|
||||
if stream == nil {
|
||||
stream = map[string]any{}
|
||||
}
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
proxy := map[string]any{"name": name, "udp": true}
|
||||
|
||||
switch protocol {
|
||||
case "vmess":
|
||||
vnext, _ := settings["vnext"].([]any)
|
||||
if len(vnext) == 0 {
|
||||
return nil
|
||||
}
|
||||
vn, _ := vnext[0].(map[string]any)
|
||||
users, _ := vn["users"].([]any)
|
||||
if vn == nil || len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
user, _ := users[0].(map[string]any)
|
||||
proxy["type"] = "vmess"
|
||||
proxy["server"] = fmt.Sprint(vn["address"])
|
||||
proxy["port"] = clashInt(vn["port"])
|
||||
proxy["uuid"] = fmt.Sprint(user["id"])
|
||||
proxy["alterId"] = 0
|
||||
cipher, _ := user["security"].(string)
|
||||
if cipher == "" {
|
||||
cipher = "auto"
|
||||
}
|
||||
proxy["cipher"] = cipher
|
||||
case "vless":
|
||||
proxy["type"] = "vless"
|
||||
proxy["server"] = fmt.Sprint(settings["address"])
|
||||
proxy["port"] = clashInt(settings["port"])
|
||||
proxy["uuid"] = fmt.Sprint(settings["id"])
|
||||
if flow, _ := settings["flow"].(string); flow != "" {
|
||||
proxy["flow"] = flow
|
||||
}
|
||||
case "trojan":
|
||||
server := firstServer(settings)
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
proxy["type"] = "trojan"
|
||||
proxy["server"] = fmt.Sprint(server["address"])
|
||||
proxy["port"] = clashInt(server["port"])
|
||||
proxy["password"] = fmt.Sprint(server["password"])
|
||||
case "shadowsocks":
|
||||
server := firstServer(settings)
|
||||
if server == nil {
|
||||
server = settings
|
||||
}
|
||||
method, _ := server["method"].(string)
|
||||
if method == "" {
|
||||
return nil
|
||||
}
|
||||
proxy["type"] = "ss"
|
||||
proxy["server"] = fmt.Sprint(server["address"])
|
||||
proxy["port"] = clashInt(server["port"])
|
||||
proxy["cipher"] = method
|
||||
proxy["password"] = fmt.Sprint(server["password"])
|
||||
return proxy
|
||||
case "hysteria":
|
||||
return clashHysteriaFromExternal(settings, stream, name)
|
||||
case "wireguard":
|
||||
return clashWireguardFromExternal(settings, name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
network, _ := stream["network"].(string)
|
||||
if !s.applyTransport(proxy, network, stream) {
|
||||
return nil
|
||||
}
|
||||
security, _ := stream["security"].(string)
|
||||
if !s.applySecurity(proxy, security, stream) {
|
||||
return nil
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
func firstServer(settings map[string]any) map[string]any {
|
||||
servers, _ := settings["servers"].([]any)
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
server, _ := servers[0].(map[string]any)
|
||||
return server
|
||||
}
|
||||
|
||||
func clashHysteriaFromExternal(settings, stream map[string]any, name string) map[string]any {
|
||||
hy, _ := stream["hysteriaSettings"].(map[string]any)
|
||||
auth := ""
|
||||
if hy != nil {
|
||||
auth, _ = hy["auth"].(string)
|
||||
}
|
||||
if auth == "" {
|
||||
return nil
|
||||
}
|
||||
proxy := map[string]any{
|
||||
"name": name,
|
||||
"type": "hysteria2",
|
||||
"server": fmt.Sprint(settings["address"]),
|
||||
"port": clashInt(settings["port"]),
|
||||
"password": auth,
|
||||
"udp": true,
|
||||
}
|
||||
if tls, _ := stream["tlsSettings"].(map[string]any); tls != nil {
|
||||
if sni, _ := tls["serverName"].(string); sni != "" {
|
||||
proxy["sni"] = sni
|
||||
}
|
||||
if alpn := clashStringList(tls["alpn"]); len(alpn) > 0 {
|
||||
proxy["alpn"] = alpn
|
||||
}
|
||||
if fp, _ := tls["fingerprint"].(string); fp != "" {
|
||||
proxy["client-fingerprint"] = fp
|
||||
}
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
func clashWireguardFromExternal(settings map[string]any, name string) map[string]any {
|
||||
peers, _ := settings["peers"].([]any)
|
||||
if len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
peer, _ := peers[0].(map[string]any)
|
||||
if peer == nil {
|
||||
return nil
|
||||
}
|
||||
host, port := splitClashHostPort(fmt.Sprint(peer["endpoint"]))
|
||||
if host == "" || port == 0 {
|
||||
return nil
|
||||
}
|
||||
proxy := map[string]any{
|
||||
"name": name,
|
||||
"type": "wireguard",
|
||||
"server": host,
|
||||
"port": port,
|
||||
"udp": true,
|
||||
}
|
||||
if sk, _ := settings["secretKey"].(string); sk != "" {
|
||||
proxy["private-key"] = sk
|
||||
}
|
||||
if pk, _ := peer["publicKey"].(string); pk != "" {
|
||||
proxy["public-key"] = pk
|
||||
}
|
||||
if psk, _ := peer["preSharedKey"].(string); psk != "" {
|
||||
proxy["pre-shared-key"] = psk
|
||||
}
|
||||
for _, addr := range clashStringList(settings["address"]) {
|
||||
ip := stripCIDR(addr)
|
||||
if strings.Contains(ip, ":") {
|
||||
proxy["ipv6"] = ip
|
||||
} else {
|
||||
proxy["ip"] = ip
|
||||
}
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
func clashInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func clashStringList(v any) []string {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return x
|
||||
case string:
|
||||
if x == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(x, ",")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stripCIDR(addr string) string {
|
||||
if i := strings.IndexByte(addr, '/'); i >= 0 {
|
||||
return addr[:i]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func splitClashHostPort(endpoint string) (string, int) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
i := strings.LastIndex(endpoint, ":")
|
||||
if i < 0 {
|
||||
return endpoint, 0
|
||||
}
|
||||
host := strings.Trim(endpoint[:i], "[]")
|
||||
port, _ := strconv.Atoi(endpoint[i+1:])
|
||||
return host, port
|
||||
}
|
||||
@@ -25,9 +25,16 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
// Set per-request state so resolveInboundAddress sees the node map.
|
||||
s.SubService.PrepareForRequest(host)
|
||||
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
||||
if err != nil || len(inbounds) == 0 {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(inbounds) == 0 && len(externalLinks) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var proxies []map[string]any
|
||||
|
||||
@@ -43,6 +50,18 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
||||
}
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
for _, el := range expandEntry(ext) {
|
||||
name := el.Name
|
||||
if name == "" {
|
||||
name = ext.Email
|
||||
}
|
||||
if proxy := s.clashProxyFromExternal(el.Link, name); proxy != nil {
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
proxies = append(proxies, proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(proxies) == 0 {
|
||||
return "", "", nil
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
)
|
||||
|
||||
// externalLinkEntry is one client × external-link row, resolved for a
|
||||
// subscription request. Email/Enable come from the owning client.
|
||||
type externalLinkEntry struct {
|
||||
Kind string
|
||||
Value string
|
||||
Remark string
|
||||
Email string
|
||||
Enable bool
|
||||
}
|
||||
|
||||
// expandedLink is a single share link contributed by an entry, with the display
|
||||
// name to use (empty → keep the link's own remark / fall back to the email).
|
||||
type expandedLink struct {
|
||||
Link string
|
||||
Name string
|
||||
}
|
||||
|
||||
// getClientExternalLinksBySubId returns every external-link row attached to a
|
||||
// client that carries the given subId, in stable order. Stays inside
|
||||
// internal/sub + database + util/link — no dependency on the panel service layer.
|
||||
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
|
||||
db := database.GetDB()
|
||||
var recs []model.ClientRecord
|
||||
if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
clientIds := make([]int, 0, len(recs))
|
||||
byId := make(map[int]model.ClientRecord, len(recs))
|
||||
for _, rec := range recs {
|
||||
clientIds = append(clientIds, rec.Id)
|
||||
byId[rec.Id] = rec
|
||||
}
|
||||
|
||||
var rows []model.ClientExternalLink
|
||||
if err := db.Where("client_id IN ?", clientIds).
|
||||
Order("client_id ASC, sort_index ASC, id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := make([]externalLinkEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
rec := byId[r.ClientId]
|
||||
out = append(out, externalLinkEntry{
|
||||
Kind: r.Kind,
|
||||
Value: r.Value,
|
||||
Remark: r.Remark,
|
||||
Email: rec.Email,
|
||||
Enable: rec.Enable,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expandEntry turns one entry into the concrete share links it contributes. A
|
||||
// "subscription" entry is fetched (cached) and its links are kept with their own
|
||||
// names; a "link" entry yields the single link with the row's remark.
|
||||
func expandEntry(e externalLinkEntry) []expandedLink {
|
||||
if e.Kind == model.ExternalLinkKindSubscription {
|
||||
links := fetchSubscriptionLinks(e.Value)
|
||||
out := make([]expandedLink, 0, len(links))
|
||||
for _, l := range links {
|
||||
out = append(out, expandedLink{Link: l, Name: ""})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return []expandedLink{{Link: e.Value, Name: e.Remark}}
|
||||
}
|
||||
|
||||
// applyRemarkToLink rewrites a share link's display name to remark (when set),
|
||||
// leaving everything else byte-for-byte. vmess carries its remark in the base64
|
||||
// JSON `ps`; every other scheme carries it in the URL #fragment.
|
||||
func applyRemarkToLink(rawLink, remark string) string {
|
||||
rawLink = strings.TrimSpace(rawLink)
|
||||
if remark == "" {
|
||||
return rawLink
|
||||
}
|
||||
if strings.HasPrefix(rawLink, "vmess://") {
|
||||
return applyVmessRemark(rawLink, remark)
|
||||
}
|
||||
if i := strings.IndexByte(rawLink, '#'); i >= 0 {
|
||||
rawLink = rawLink[:i]
|
||||
}
|
||||
return rawLink + "#" + url.PathEscape(remark)
|
||||
}
|
||||
|
||||
func applyVmessRemark(rawLink, remark string) string {
|
||||
b64 := strings.TrimPrefix(rawLink, "vmess://")
|
||||
raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
|
||||
if err != nil {
|
||||
raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
|
||||
}
|
||||
if err != nil {
|
||||
return rawLink
|
||||
}
|
||||
var j map[string]any
|
||||
if err := json.Unmarshal(raw, &j); err != nil {
|
||||
return rawLink
|
||||
}
|
||||
j["ps"] = remark
|
||||
nb, err := json.Marshal(j)
|
||||
if err != nil {
|
||||
return rawLink
|
||||
}
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(nb)
|
||||
}
|
||||
|
||||
func padBase64Sub(s string) string {
|
||||
for len(s)%4 != 0 {
|
||||
s += "="
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parsedExternalOutbound turns a pasted share link into a structured Xray
|
||||
// outbound (tagged "proxy") for the JSON subscription. Returns nil when the
|
||||
// link can't be parsed — the caller skips it.
|
||||
func parsedExternalOutbound(rawLink string) json_util.RawMessage {
|
||||
ob := parseExternalLink(rawLink)
|
||||
if ob == nil {
|
||||
return nil
|
||||
}
|
||||
ob["tag"] = "proxy"
|
||||
b, err := json.MarshalIndent(ob, "", " ")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// parseExternalLink parses a share link into the Xray outbound wire shape
|
||||
// (map), or nil if unsupported/invalid.
|
||||
func parseExternalLink(rawLink string) map[string]any {
|
||||
res, err := link.ParseLink(strings.TrimSpace(rawLink))
|
||||
if err != nil || res == nil || res.Outbound == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any(res.Outbound)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestApplyRemarkToLinkRewritesFragment(t *testing.T) {
|
||||
link := "vless://uuid@example.com:443?security=reality&pbk=abc&sid=12#old-name"
|
||||
out := applyRemarkToLink(link, "DE-Provider")
|
||||
u, err := url.Parse(out)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if u.Fragment != "DE-Provider" {
|
||||
t.Fatalf("fragment = %q, want DE-Provider", u.Fragment)
|
||||
}
|
||||
// Everything before the fragment must be byte-for-byte preserved.
|
||||
if !strings.HasPrefix(out, "vless://uuid@example.com:443?security=reality&pbk=abc&sid=12#") {
|
||||
t.Fatalf("link body altered: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemarkToLinkVmessSetsPs(t *testing.T) {
|
||||
payload := map[string]any{"v": "2", "ps": "old", "add": "1.2.3.4", "port": "443", "id": "uuid"}
|
||||
b, _ := json.Marshal(payload)
|
||||
link := "vmess://" + base64.StdEncoding.EncodeToString(b)
|
||||
|
||||
out := applyRemarkToLink(link, "NL-Node")
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got["ps"] != "NL-Node" {
|
||||
t.Fatalf("ps = %v, want NL-Node", got["ps"])
|
||||
}
|
||||
if got["id"] != "uuid" {
|
||||
t.Fatalf("credentials lost: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemarkEmptyKeepsLinkVerbatim(t *testing.T) {
|
||||
link := "trojan://pass@1.2.3.4:8443?security=tls#orig"
|
||||
if out := applyRemarkToLink(link, ""); out != link {
|
||||
t.Fatalf("empty remark altered link: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedExternalOutboundTagsProxy(t *testing.T) {
|
||||
link := "vless://uuid@example.com:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#srv"
|
||||
data := parsedExternalOutbound(link)
|
||||
if data == nil {
|
||||
t.Fatal("expected an outbound, got nil")
|
||||
}
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal(data, &ob); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if ob["tag"] != "proxy" {
|
||||
t.Fatalf("tag = %v, want proxy", ob["tag"])
|
||||
}
|
||||
if ob["protocol"] != "vless" {
|
||||
t.Fatalf("protocol = %v, want vless", ob["protocol"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSubscriptionBodyBase64(t *testing.T) {
|
||||
plain := "vless://uuid@a.com:443#one\ntrojan://pw@b.com:8443#two\n"
|
||||
body := []byte(base64.StdEncoding.EncodeToString([]byte(plain)))
|
||||
links := decodeSubscriptionBody(body)
|
||||
if len(links) != 2 || links[0] != "vless://uuid@a.com:443#one" || links[1] != "trojan://pw@b.com:8443#two" {
|
||||
t.Fatalf("decoded links = %#v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSubscriptionBodyPlainSkipsComments(t *testing.T) {
|
||||
body := []byte("# header\nvmess://abc\n\nnot-a-link\nss://def#x\n")
|
||||
links := decodeSubscriptionBody(body)
|
||||
if len(links) != 2 || links[0] != "vmess://abc" || links[1] != "ss://def#x" {
|
||||
t.Fatalf("decoded links = %#v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandEntryLinkAppliesRemark(t *testing.T) {
|
||||
got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@b.com:8443#orig", Remark: "DE"})
|
||||
if len(got) != 1 || got[0].Name != "DE" {
|
||||
t.Fatalf("expandEntry = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
|
||||
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
|
||||
svc := NewSubClashService(false, "", NewSubService(false, "-io"))
|
||||
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
|
||||
if proxy == nil {
|
||||
t.Fatal("expected a clash proxy, got nil")
|
||||
}
|
||||
if proxy["type"] != "trojan" {
|
||||
t.Fatalf("type = %v, want trojan", proxy["type"])
|
||||
}
|
||||
if proxy["server"] != "37.27.201.56" {
|
||||
t.Fatalf("server = %v", proxy["server"])
|
||||
}
|
||||
if proxy["password"] != "provider-pass" {
|
||||
t.Fatalf("password = %v", proxy["password"])
|
||||
}
|
||||
if proxy["name"] != "DE-Provider" {
|
||||
t.Fatalf("name = %v", proxy["name"])
|
||||
}
|
||||
if proxy["tls"] != true {
|
||||
t.Fatalf("expected reality→tls true, got %v", proxy["tls"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// External subscription fetching: a "subscription" external link is a remote
|
||||
// URL whose body is a (often base64-encoded) newline list of share links. We
|
||||
// fetch it on demand, cache the decoded links briefly, and bound the request
|
||||
// with a short timeout so a slow/dead provider can't stall a client's sub.
|
||||
|
||||
const (
|
||||
subscriptionCacheTTL = 5 * time.Minute
|
||||
subscriptionMaxBytes = 2 << 20 // 2 MiB
|
||||
)
|
||||
|
||||
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
|
||||
|
||||
type subscriptionCacheEntry struct {
|
||||
links []string
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var subscriptionCache = struct {
|
||||
sync.Mutex
|
||||
m map[string]subscriptionCacheEntry
|
||||
}{m: make(map[string]subscriptionCacheEntry)}
|
||||
|
||||
// fetchSubscriptionLinks returns the share links contained in a remote
|
||||
// subscription URL, using a short-lived cache. On any failure it returns the
|
||||
// last cached value (if present) or nil — never an error, so the rest of the
|
||||
// client's subscription still renders.
|
||||
func fetchSubscriptionLinks(rawURL string) []string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
subscriptionCache.Lock()
|
||||
cached, ok := subscriptionCache.m[rawURL]
|
||||
subscriptionCache.Unlock()
|
||||
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
|
||||
return cached.links
|
||||
}
|
||||
|
||||
links, err := doFetchSubscriptionLinks(rawURL)
|
||||
if err != nil {
|
||||
// Serve stale on error rather than dropping the client's configs.
|
||||
if ok {
|
||||
return cached.links
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
subscriptionCache.Lock()
|
||||
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
|
||||
subscriptionCache.Unlock()
|
||||
return links
|
||||
}
|
||||
|
||||
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Some providers gate the link body on a known client User-Agent.
|
||||
req.Header.Set("User-Agent", "v2rayNG/1.8.5")
|
||||
resp, err := subscriptionHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, errBadStatus
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeSubscriptionBody(body), nil
|
||||
}
|
||||
|
||||
var errBadStatus = &subError{"non-2xx subscription response"}
|
||||
|
||||
type subError struct{ msg string }
|
||||
|
||||
func (e *subError) Error() string { return e.msg }
|
||||
|
||||
// decodeSubscriptionBody handles the common base64-encoded newline list as well
|
||||
// as a plain-text body, returning only the lines that look like share links.
|
||||
func decodeSubscriptionBody(body []byte) []string {
|
||||
text := strings.TrimSpace(string(body))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if decoded, ok := tryDecodeBase64Body(text); ok {
|
||||
text = strings.TrimSpace(decoded)
|
||||
}
|
||||
lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' })
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" || strings.HasPrefix(ln, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(ln, "://") {
|
||||
out = append(out, ln)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tryDecodeBase64Body(s string) (string, bool) {
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
if b, err := base64.StdEncoding.DecodeString(padBase64Sub(clean)); err == nil {
|
||||
return string(b), true
|
||||
}
|
||||
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(clean, "=")); err == nil {
|
||||
return string(b), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -62,9 +62,16 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||
// resolveInboundAddress call inside picks node-aware host values.
|
||||
s.SubService.PrepareForRequest(host)
|
||||
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
||||
if err != nil || len(inbounds) == 0 {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(inbounds) == 0 && len(externalLinks) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var header string
|
||||
var configArray []json_util.RawMessage
|
||||
@@ -83,6 +90,27 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||
configArray = append(configArray, s.getConfig(inbound, client, host)...)
|
||||
}
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
for _, el := range expandEntry(ext) {
|
||||
outbound := parsedExternalOutbound(el.Link)
|
||||
if outbound == nil {
|
||||
continue
|
||||
}
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
remark := el.Name
|
||||
if remark == "" {
|
||||
remark = ext.Email
|
||||
}
|
||||
newOutbounds := []json_util.RawMessage{outbound}
|
||||
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
||||
newConfigJson := make(map[string]any)
|
||||
maps.Copy(newConfigJson, s.configJson)
|
||||
newConfigJson["outbounds"] = newOutbounds
|
||||
newConfigJson["remarks"] = remark
|
||||
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
||||
configArray = append(configArray, newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configArray) == 0 {
|
||||
return "", "", nil
|
||||
|
||||
+17
-1
@@ -147,8 +147,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
|
||||
if err != nil {
|
||||
return nil, nil, 0, traffic, err
|
||||
}
|
||||
externalLinks, err := s.getClientExternalLinksBySubId(subId)
|
||||
if err != nil {
|
||||
return nil, nil, 0, traffic, err
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
if len(inbounds) == 0 && len(externalLinks) == 0 {
|
||||
return nil, nil, 0, traffic, nil
|
||||
}
|
||||
|
||||
@@ -178,6 +182,18 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
if ext.Enable {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
for _, el := range expandEntry(ext) {
|
||||
if link := applyRemarkToLink(el.Link, el.Name); link != "" {
|
||||
result = append(result, link)
|
||||
emails = append(emails, ext.Email)
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uniqueEmails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
|
||||
@@ -59,6 +59,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/del/:email", a.delete)
|
||||
g.POST("/:email/attach", a.attach)
|
||||
g.POST("/:email/detach", a.detach)
|
||||
g.POST("/:email/externalLinks", a.setExternalLinks)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/delDepleted", a.delDepleted)
|
||||
g.POST("/bulkAdjust", a.bulkAdjust)
|
||||
@@ -112,6 +113,11 @@ func (a *ClientController) get(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
@@ -125,7 +131,7 @@ func (a *ClientController) get(c *gin.Context) {
|
||||
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
|
||||
usedTraffic = t.Up + t.Down
|
||||
}
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "usedTraffic": usedTraffic}, nil)
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) create(c *gin.Context) {
|
||||
@@ -185,6 +191,10 @@ type attachDetachBody struct {
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
type externalLinksBody struct {
|
||||
ExternalLinks []service.ExternalLinkInput `json:"externalLinks"`
|
||||
}
|
||||
|
||||
func (a *ClientController) attach(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var body attachDetachBody
|
||||
@@ -204,6 +214,21 @@ func (a *ClientController) attach(c *gin.Context) {
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) setExternalLinks(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var body externalLinksBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.clientService.SetExternalLinksByEmail(email, body.ExternalLinks); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) resetAllTraffics(c *gin.Context) {
|
||||
needRestart, err := a.clientService.ResetAllTraffics()
|
||||
if err != nil {
|
||||
|
||||
@@ -407,6 +407,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ExternalLinkInput is one row from the client form's Links tab.
|
||||
type ExternalLinkInput struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func (s *ClientService) GetExternalLinksForRecord(id int) ([]model.ClientExternalLink, error) {
|
||||
var rows []model.ClientExternalLink
|
||||
if err := database.GetDB().
|
||||
Where("client_id = ?", id).
|
||||
Order("sort_index ASC, id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// normalizeExternalLinks validates and orders the incoming rows. A "link" must
|
||||
// parse to a supported share-link scheme; a "subscription" must be an http(s)
|
||||
// URL. Blank values are dropped; an invalid value is a hard error so the
|
||||
// operator gets immediate feedback instead of a silently missing config.
|
||||
func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalLink, error) {
|
||||
out := make([]model.ClientExternalLink, 0, len(inputs))
|
||||
for _, in := range inputs {
|
||||
value := strings.TrimSpace(in.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
kind := strings.TrimSpace(in.Kind)
|
||||
switch kind {
|
||||
case model.ExternalLinkKindSubscription:
|
||||
if !isHTTPURL(value) {
|
||||
return nil, common.NewError("external subscription must be an http(s) URL: " + value)
|
||||
}
|
||||
case model.ExternalLinkKindLink, "":
|
||||
kind = model.ExternalLinkKindLink
|
||||
if _, err := link.ParseLink(value); err != nil {
|
||||
return nil, common.NewError("unsupported or invalid share link: " + value)
|
||||
}
|
||||
default:
|
||||
return nil, common.NewError("unknown external link kind: " + kind)
|
||||
}
|
||||
out = append(out, model.ClientExternalLink{
|
||||
Kind: kind,
|
||||
Value: value,
|
||||
Remark: strings.TrimSpace(in.Remark),
|
||||
SortIndex: len(out),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isHTTPURL(s string) bool {
|
||||
u, err := url.Parse(s)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
// SetExternalLinksForRecord replaces a client's entire external-link set.
|
||||
func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkInput) error {
|
||||
rows, err := normalizeExternalLinks(inputs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db := database.GetDB()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].ClientId = id
|
||||
if err := tx.Create(&rows[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ClientService) SetExternalLinksByEmail(email string, inputs []ExternalLinkInput) error {
|
||||
if strings.TrimSpace(email) == "" {
|
||||
return common.NewError("client email is required")
|
||||
}
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.SetExternalLinksForRecord(rec.Id, inputs)
|
||||
}
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "أساسي",
|
||||
"tabCredentials": "بيانات الاعتماد",
|
||||
"tabLinks": "الروابط",
|
||||
"linksHint": "أضف روابط مشاركة من جهات خارجية وعناوين اشتراك خارجية لتضمينها في اشتراك هذا العميل.",
|
||||
"addExternalLink": "إضافة رابط خارجي",
|
||||
"addExternalSubscription": "إضافة اشتراك خارجي",
|
||||
"noExternalLinks": "لا توجد روابط خارجية بعد.",
|
||||
"noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
|
||||
"add": "إضافة عميل",
|
||||
"edit": "تعديل العميل",
|
||||
"submitAdd": "إضافة عميل",
|
||||
|
||||
@@ -648,6 +648,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Basics",
|
||||
"tabCredentials": "Credentials",
|
||||
"tabLinks": "Links",
|
||||
"linksHint": "Add third-party share links and remote subscription URLs to include in this client's subscription.",
|
||||
"addExternalLink": "Add External Link",
|
||||
"addExternalSubscription": "Add External Subscription",
|
||||
"noExternalLinks": "No external links yet.",
|
||||
"noExternalSubscriptions": "No external subscriptions yet.",
|
||||
"add": "Add Client",
|
||||
"edit": "Edit Client",
|
||||
"submitAdd": "Add Client",
|
||||
@@ -1765,4 +1771,4 @@
|
||||
"chooseInbound": "Choose an Inbound"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Básico",
|
||||
"tabCredentials": "Credenciales",
|
||||
"tabLinks": "Enlaces",
|
||||
"linksHint": "Añade enlaces de terceros y URLs de suscripción remotas para incluirlos en la suscripción de este cliente.",
|
||||
"addExternalLink": "Añadir enlace externo",
|
||||
"addExternalSubscription": "Añadir suscripción externa",
|
||||
"noExternalLinks": "Aún no hay enlaces externos.",
|
||||
"noExternalSubscriptions": "Aún no hay suscripciones externas.",
|
||||
"add": "Añadir cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Añadir cliente",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "پایه",
|
||||
"tabCredentials": "اطلاعات اتصال",
|
||||
"tabLinks": "لینکها",
|
||||
"linksHint": "لینکهای اشتراک شخصثالث و آدرس سابسکریپشنهای خارجی را اضافه کنید تا در سابسکریپشن این کاربر قرار گیرند.",
|
||||
"addExternalLink": "افزودن لینک خارجی",
|
||||
"addExternalSubscription": "افزودن سابسکریپشن خارجی",
|
||||
"noExternalLinks": "هنوز لینک خارجیای اضافه نشده.",
|
||||
"noExternalSubscriptions": "هنوز سابسکریپشن خارجیای اضافه نشده.",
|
||||
"add": "افزودن کلاینت",
|
||||
"edit": "ویرایش کلاینت",
|
||||
"submitAdd": "افزودن کلاینت",
|
||||
@@ -1764,4 +1770,4 @@
|
||||
"chooseInbound": "یک ورودی انتخاب کنید"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Dasar",
|
||||
"tabCredentials": "Kredensial",
|
||||
"tabLinks": "Tautan",
|
||||
"linksHint": "Tambahkan tautan berbagi pihak ketiga dan URL langganan jarak jauh untuk disertakan dalam langganan klien ini.",
|
||||
"addExternalLink": "Tambah Tautan Eksternal",
|
||||
"addExternalSubscription": "Tambah Langganan Eksternal",
|
||||
"noExternalLinks": "Belum ada tautan eksternal.",
|
||||
"noExternalSubscriptions": "Belum ada langganan eksternal.",
|
||||
"add": "Tambah klien",
|
||||
"edit": "Ubah klien",
|
||||
"submitAdd": "Tambah klien",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "基本",
|
||||
"tabCredentials": "認証情報",
|
||||
"tabLinks": "リンク",
|
||||
"linksHint": "サードパーティの共有リンクやリモートのサブスクリプションURLを追加して、このクライアントのサブスクリプションに含めます。",
|
||||
"addExternalLink": "外部リンクを追加",
|
||||
"addExternalSubscription": "外部サブスクリプションを追加",
|
||||
"noExternalLinks": "外部リンクはまだありません。",
|
||||
"noExternalSubscriptions": "外部サブスクリプションはまだありません。",
|
||||
"add": "クライアントを追加",
|
||||
"edit": "クライアントを編集",
|
||||
"submitAdd": "クライアントを追加",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Básico",
|
||||
"tabCredentials": "Credenciais",
|
||||
"tabLinks": "Links",
|
||||
"linksHint": "Adicione links de terceiros e URLs de assinatura remotas para incluir na assinatura deste cliente.",
|
||||
"addExternalLink": "Adicionar link externo",
|
||||
"addExternalSubscription": "Adicionar assinatura externa",
|
||||
"noExternalLinks": "Ainda não há links externos.",
|
||||
"noExternalSubscriptions": "Ainda não há assinaturas externas.",
|
||||
"add": "Adicionar cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Adicionar cliente",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Основные",
|
||||
"tabCredentials": "Учетные данные",
|
||||
"tabLinks": "Ссылки",
|
||||
"linksHint": "Добавьте сторонние ссылки и URL удалённых подписок, чтобы включить их в подписку этого клиента.",
|
||||
"addExternalLink": "Добавить внешнюю ссылку",
|
||||
"addExternalSubscription": "Добавить внешнюю подписку",
|
||||
"noExternalLinks": "Пока нет внешних ссылок.",
|
||||
"noExternalSubscriptions": "Пока нет внешних подписок.",
|
||||
"add": "Добавить клиента",
|
||||
"edit": "Изменить клиента",
|
||||
"submitAdd": "Добавить клиента",
|
||||
|
||||
@@ -648,6 +648,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Temel",
|
||||
"tabCredentials": "Kimlik Bilgileri",
|
||||
"tabLinks": "Bağlantılar",
|
||||
"linksHint": "Bu istemcinin aboneliğine dahil etmek için üçüncü taraf paylaşım bağlantıları ve uzak abonelik URL'leri ekleyin.",
|
||||
"addExternalLink": "Harici Bağlantı Ekle",
|
||||
"addExternalSubscription": "Harici Abonelik Ekle",
|
||||
"noExternalLinks": "Henüz harici bağlantı yok.",
|
||||
"noExternalSubscriptions": "Henüz harici abonelik yok.",
|
||||
"add": "Kullanıcı Ekle",
|
||||
"edit": "Kullanıcıyı Düzenle",
|
||||
"submitAdd": "Kullanıcı Ekle",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Основні",
|
||||
"tabCredentials": "Облікові дані",
|
||||
"tabLinks": "Посилання",
|
||||
"linksHint": "Додайте сторонні посилання та URL віддалених підписок, щоб включити їх до підписки цього клієнта.",
|
||||
"addExternalLink": "Додати зовнішнє посилання",
|
||||
"addExternalSubscription": "Додати зовнішню підписку",
|
||||
"noExternalLinks": "Зовнішніх посилань ще немає.",
|
||||
"noExternalSubscriptions": "Зовнішніх підписок ще немає.",
|
||||
"add": "Додати клієнта",
|
||||
"edit": "Редагувати клієнта",
|
||||
"submitAdd": "Додати клієнта",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Cơ bản",
|
||||
"tabCredentials": "Thông tin xác thực",
|
||||
"tabLinks": "Liên kết",
|
||||
"linksHint": "Thêm liên kết chia sẻ của bên thứ ba và URL đăng ký từ xa để đưa vào đăng ký của khách hàng này.",
|
||||
"addExternalLink": "Thêm liên kết ngoài",
|
||||
"addExternalSubscription": "Thêm đăng ký ngoài",
|
||||
"noExternalLinks": "Chưa có liên kết ngoài.",
|
||||
"noExternalSubscriptions": "Chưa có đăng ký ngoài.",
|
||||
"add": "Thêm khách hàng",
|
||||
"edit": "Chỉnh sửa khách hàng",
|
||||
"submitAdd": "Thêm khách hàng",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "基本",
|
||||
"tabCredentials": "凭据",
|
||||
"tabLinks": "链接",
|
||||
"linksHint": "添加第三方分享链接和远程订阅地址,将其包含在该客户端的订阅中。",
|
||||
"addExternalLink": "添加外部链接",
|
||||
"addExternalSubscription": "添加外部订阅",
|
||||
"noExternalLinks": "暂无外部链接。",
|
||||
"noExternalSubscriptions": "暂无外部订阅。",
|
||||
"add": "添加客户端",
|
||||
"edit": "编辑客户端",
|
||||
"submitAdd": "添加客户端",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "基本",
|
||||
"tabCredentials": "認證資訊",
|
||||
"tabLinks": "連結",
|
||||
"linksHint": "新增第三方分享連結和遠端訂閱網址,將其包含在此用戶端的訂閱中。",
|
||||
"addExternalLink": "新增外部連結",
|
||||
"addExternalSubscription": "新增外部訂閱",
|
||||
"noExternalLinks": "尚無外部連結。",
|
||||
"noExternalSubscriptions": "尚無外部訂閱。",
|
||||
"add": "新增客戶端",
|
||||
"edit": "編輯客戶端",
|
||||
"submitAdd": "新增客戶端",
|
||||
|
||||
Reference in New Issue
Block a user