From dcb923b4a144f5f682d354fd1d7d267973a8b2db Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 14 Jun 2026 20:57:14 +0200 Subject: [PATCH] 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. --- frontend/public/openapi.json | 70 +++++- .../components/form/SelectAllClearButtons.tsx | 16 +- frontend/src/hooks/useClients.ts | 18 +- frontend/src/pages/api-docs/endpoints.ts | 15 +- .../src/pages/clients/ClientFormModal.tsx | 111 +++++++- frontend/src/pages/clients/ClientsPage.tsx | 25 +- frontend/src/schemas/client.ts | 12 + internal/database/db.go | 1 + internal/database/migrate_data.go | 1 + internal/database/model/model.go | 25 ++ internal/sub/clash_external.go | 238 ++++++++++++++++++ internal/sub/clash_service.go | 21 +- internal/sub/external_config.go | 160 ++++++++++++ internal/sub/external_config_test.go | 123 +++++++++ internal/sub/external_subscription.go | 133 ++++++++++ internal/sub/json_service.go | 30 ++- internal/sub/service.go | 18 +- internal/web/controller/client.go | 27 +- internal/web/service/client_crud.go | 3 + internal/web/service/client_external_link.go | 103 ++++++++ internal/web/translation/ar-EG.json | 6 + internal/web/translation/en-US.json | 8 +- internal/web/translation/es-ES.json | 6 + internal/web/translation/fa-IR.json | 8 +- internal/web/translation/id-ID.json | 6 + internal/web/translation/ja-JP.json | 6 + internal/web/translation/pt-BR.json | 6 + internal/web/translation/ru-RU.json | 6 + internal/web/translation/tr-TR.json | 6 + internal/web/translation/uk-UA.json | 6 + internal/web/translation/vi-VN.json | 6 + internal/web/translation/zh-CN.json | 6 + internal/web/translation/zh-TW.json | 6 + 33 files changed, 1204 insertions(+), 28 deletions(-) create mode 100644 internal/sub/clash_external.go create mode 100644 internal/sub/external_config.go create mode 100644 internal/sub/external_config_test.go create mode 100644 internal/sub/external_subscription.go create mode 100644 internal/web/service/client_external_link.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 2d72a3028..e7581aaec 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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": [ diff --git a/frontend/src/components/form/SelectAllClearButtons.tsx b/frontend/src/components/form/SelectAllClearButtons.tsx index 2c3c71c04..a5a46a19b 100644 --- a/frontend/src/components/form/SelectAllClearButtons.tsx +++ b/frontend/src/components/form/SelectAllClearButtons.tsx @@ -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 { + 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({ options, value, onChange, selectAllLabel, clearLabel, -}: SelectAllClearButtonsProps) { +}: SelectAllClearButtonsProps) { const { t } = useTranslation(); const optionValues = options.map((o) => o.value); diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 73203119d..5af2429a1 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -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> => { 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> => { 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); return attachMut.mutateAsync({ email, inboundIds }); }, [attachMut]); + const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => { + if (!email) return Promise.resolve(null as unknown as Msg); + 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); if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg); @@ -553,6 +568,7 @@ export function useClients() { bulkAddToGroup, bulkRemoveFromGroup, attach, + setExternalLinks, bulkAttach, detach, bulkDetach, diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index e7837bbb0..0da6e4676 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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', diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index aa43317cc..143da21d0 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -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 { 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: ( + <> + + {t('pages.clients.linksHint')} + + + +
+ {linkRows.length === 0 ? ( + {t('pages.clients.noExternalLinks')} + ) : linkRows.map((row) => ( +
+ updateExternalLinkRow(row.key, e.target.value)} + placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://" + /> + +
+ ))} +
+ + +
+ {subscriptionRows.length === 0 ? ( + {t('pages.clients.noExternalSubscriptions')} + ) : subscriptionRows.map((row) => ( +
+ updateExternalLinkRow(row.key, e.target.value)} + placeholder="https://provider.example/sub/…" + /> + +
+ ))} +
+ + ), + }, ]} /> diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 9db5acfcd..8721ffc35 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -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(null); const [editingAttachedIds, setEditingAttachedIds] = useState([]); + const [editingExternalLinks, setEditingExternalLinks] = useState([]); const [infoOpen, setInfoOpen] = useState(false); const [infoClient, setInfoClient] = useState(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 | { client: Record; 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} diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 916812280..c3d57d83a 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -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; export type ClientTraffic = z.infer; export type InboundOption = z.infer; +export type ExternalLink = z.infer; export type ClientsSummary = z.infer; export type ClientPageResponse = z.infer; export type ClientHydrate = z.infer; diff --git a/internal/database/db.go b/internal/database/db.go index 858be4d6e..5475f74f7 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -69,6 +69,7 @@ func initModels() error { &model.ApiToken{}, &model.ClientRecord{}, &model.ClientInbound{}, + &model.ClientExternalLink{}, &model.ClientGroup{}, &model.InboundFallback{}, &model.NodeClientTraffic{}, diff --git a/internal/database/migrate_data.go b/internal/database/migrate_data.go index ced0882d5..9a0e357e8 100644 --- a/internal/database/migrate_data.go +++ b/internal/database/migrate_data.go @@ -47,6 +47,7 @@ func migrationModels() []any { &model.InboundClientIps{}, &model.ClientRecord{}, &model.ClientInbound{}, + &model.ClientExternalLink{}, &model.InboundFallback{}, &model.NodeClientTraffic{}, &model.OutboundSubscription{}, diff --git a/internal/database/model/model.go b/internal/database/model/model.go index faaff5bc2..d80eecf6e 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -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"` diff --git a/internal/sub/clash_external.go b/internal/sub/clash_external.go new file mode 100644 index 000000000..f0ded7505 --- /dev/null +++ b/internal/sub/clash_external.go @@ -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 +} diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index dbc2e12e7..fabd894dc 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -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 diff --git a/internal/sub/external_config.go b/internal/sub/external_config.go new file mode 100644 index 000000000..5a341f267 --- /dev/null +++ b/internal/sub/external_config.go @@ -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) +} diff --git a/internal/sub/external_config_test.go b/internal/sub/external_config_test.go new file mode 100644 index 000000000..c078bd82c --- /dev/null +++ b/internal/sub/external_config_test.go @@ -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"]) + } +} diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go new file mode 100644 index 000000000..4419abc75 --- /dev/null +++ b/internal/sub/external_subscription.go @@ -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 +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 609052642..4fa6f127f 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -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 diff --git a/internal/sub/service.go b/internal/sub/service.go index d9b40770c..7b5a441cc 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -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 { diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index f52c42a40..54cd66e93 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -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 { diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 1fcdca71d..565a81752 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -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 diff --git a/internal/web/service/client_external_link.go b/internal/web/service/client_external_link.go new file mode 100644 index 000000000..0a03f65de --- /dev/null +++ b/internal/web/service/client_external_link.go @@ -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) +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 5b2bf785c..556a7ba2c 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -647,6 +647,12 @@ "clients": { "tabBasics": "أساسي", "tabCredentials": "بيانات الاعتماد", + "tabLinks": "الروابط", + "linksHint": "أضف روابط مشاركة من جهات خارجية وعناوين اشتراك خارجية لتضمينها في اشتراك هذا العميل.", + "addExternalLink": "إضافة رابط خارجي", + "addExternalSubscription": "إضافة اشتراك خارجي", + "noExternalLinks": "لا توجد روابط خارجية بعد.", + "noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.", "add": "إضافة عميل", "edit": "تعديل العميل", "submitAdd": "إضافة عميل", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index d006059cf..349771f37 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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" } } -} \ No newline at end of file +} diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 08aeb42d2..25e8d6d9f 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index b318fa24d..6d2528f35 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -647,6 +647,12 @@ "clients": { "tabBasics": "پایه", "tabCredentials": "اطلاعات اتصال", + "tabLinks": "لینک‌ها", + "linksHint": "لینک‌های اشتراک شخص‌ثالث و آدرس سابسکریپشن‌های خارجی را اضافه کنید تا در سابسکریپشن این کاربر قرار گیرند.", + "addExternalLink": "افزودن لینک خارجی", + "addExternalSubscription": "افزودن سابسکریپشن خارجی", + "noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.", + "noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.", "add": "افزودن کلاینت", "edit": "ویرایش کلاینت", "submitAdd": "افزودن کلاینت", @@ -1764,4 +1770,4 @@ "chooseInbound": "یک ورودی انتخاب کنید" } } -} \ No newline at end of file +} diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index f97ea600d..82310180d 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index bf4f37e88..62846ce39 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -647,6 +647,12 @@ "clients": { "tabBasics": "基本", "tabCredentials": "認証情報", + "tabLinks": "リンク", + "linksHint": "サードパーティの共有リンクやリモートのサブスクリプションURLを追加して、このクライアントのサブスクリプションに含めます。", + "addExternalLink": "外部リンクを追加", + "addExternalSubscription": "外部サブスクリプションを追加", + "noExternalLinks": "外部リンクはまだありません。", + "noExternalSubscriptions": "外部サブスクリプションはまだありません。", "add": "クライアントを追加", "edit": "クライアントを編集", "submitAdd": "クライアントを追加", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index c915340d2..2f679b020 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 871c28d55..ac49a4c02 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -647,6 +647,12 @@ "clients": { "tabBasics": "Основные", "tabCredentials": "Учетные данные", + "tabLinks": "Ссылки", + "linksHint": "Добавьте сторонние ссылки и URL удалённых подписок, чтобы включить их в подписку этого клиента.", + "addExternalLink": "Добавить внешнюю ссылку", + "addExternalSubscription": "Добавить внешнюю подписку", + "noExternalLinks": "Пока нет внешних ссылок.", + "noExternalSubscriptions": "Пока нет внешних подписок.", "add": "Добавить клиента", "edit": "Изменить клиента", "submitAdd": "Добавить клиента", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 340acc257..2bc8fb16b 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index d737fd151..787fbd661 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -647,6 +647,12 @@ "clients": { "tabBasics": "Основні", "tabCredentials": "Облікові дані", + "tabLinks": "Посилання", + "linksHint": "Додайте сторонні посилання та URL віддалених підписок, щоб включити їх до підписки цього клієнта.", + "addExternalLink": "Додати зовнішнє посилання", + "addExternalSubscription": "Додати зовнішню підписку", + "noExternalLinks": "Зовнішніх посилань ще немає.", + "noExternalSubscriptions": "Зовнішніх підписок ще немає.", "add": "Додати клієнта", "edit": "Редагувати клієнта", "submitAdd": "Додати клієнта", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 212665598..a880b6566 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 7209a99aa..15d652228 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -647,6 +647,12 @@ "clients": { "tabBasics": "基本", "tabCredentials": "凭据", + "tabLinks": "链接", + "linksHint": "添加第三方分享链接和远程订阅地址,将其包含在该客户端的订阅中。", + "addExternalLink": "添加外部链接", + "addExternalSubscription": "添加外部订阅", + "noExternalLinks": "暂无外部链接。", + "noExternalSubscriptions": "暂无外部订阅。", "add": "添加客户端", "edit": "编辑客户端", "submitAdd": "添加客户端", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index f59ba7aae..071915539 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -647,6 +647,12 @@ "clients": { "tabBasics": "基本", "tabCredentials": "認證資訊", + "tabLinks": "連結", + "linksHint": "新增第三方分享連結和遠端訂閱網址,將其包含在此用戶端的訂閱中。", + "addExternalLink": "新增外部連結", + "addExternalSubscription": "新增外部訂閱", + "noExternalLinks": "尚無外部連結。", + "noExternalSubscriptions": "尚無外部訂閱。", "add": "新增客戶端", "edit": "編輯客戶端", "submitAdd": "新增客戶端",