From d7681240bbef2f28f25f23184ede3386369a9f77 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Wed, 29 Apr 2026 14:49:18 +1000 Subject: [PATCH] feat: support clash subscription imports --- .../aggregate-subscription-card.tsx | 19 +- .../proxy-subscription-details.tsx | 19 +- .../subscription-import-actions.tsx | 71 ++++ src/app/(user)/subscriptions/page.tsx | 4 +- src/app/api/subscription/[id]/route.ts | 31 +- src/app/api/subscription/all/route.ts | 52 ++- src/components/shared/copy-button.tsx | 4 +- src/services/subscription.ts | 362 +++++++++++++++++- 8 files changed, 517 insertions(+), 45 deletions(-) create mode 100644 src/app/(user)/subscriptions/_components/subscription-import-actions.tsx diff --git a/src/app/(user)/subscriptions/_components/aggregate-subscription-card.tsx b/src/app/(user)/subscriptions/_components/aggregate-subscription-card.tsx index d86ba46..7076b06 100644 --- a/src/app/(user)/subscriptions/_components/aggregate-subscription-card.tsx +++ b/src/app/(user)/subscriptions/_components/aggregate-subscription-card.tsx @@ -1,10 +1,10 @@ import { format } from "date-fns"; import { zhCN } from "date-fns/locale"; -import { CalendarClock, Gauge, Layers3, Link2, Sparkles } from "lucide-react"; -import { CopyButton } from "@/components/shared/copy-button"; +import { CalendarClock, Gauge, Layers3, Sparkles } from "lucide-react"; import { QrPreview } from "@/components/shared/qr-preview"; import { Progress } from "@/components/ui/progress"; import { formatBytes } from "@/lib/utils"; +import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions"; interface AggregateSubscriptionCardProps { subscriptionUrl: string; @@ -24,6 +24,7 @@ export function AggregateSubscriptionCard({ const percent = totalLimit ? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100)) : 0; + const clashUrl = withSubscriptionFormat(subscriptionUrl, "clash"); return (
@@ -44,17 +45,7 @@ export function AggregateSubscriptionCard({ -
-
-
-

- SUBSCRIPTION URL -

-

{subscriptionUrl}

-
- -
-
+
@@ -94,7 +85,7 @@ export function AggregateSubscriptionCard({
- +
diff --git a/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx b/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx index e62324c..16e47f0 100644 --- a/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx +++ b/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx @@ -1,10 +1,10 @@ import { Gauge, Link2, QrCode } from "lucide-react"; import { Progress } from "@/components/ui/progress"; -import { CopyButton } from "@/components/shared/copy-button"; import { QrPreview } from "@/components/shared/qr-preview"; import { formatBytes } from "@/lib/utils"; import { buildSingleNodeUri } from "@/services/subscription"; import type { SubscriptionRecord } from "../subscriptions-types"; +import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions"; interface ProxySubscriptionDetailsProps { sub: SubscriptionRecord; @@ -18,6 +18,7 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null; const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0; const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`; + const clashUrl = withSubscriptionFormat(subUrl, "clash"); const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : ""; return ( @@ -45,18 +46,20 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
导入信息
-
-
-

SUBSCRIPTION URL

-

{subUrl}

-
- +
+
扫码导入
- + {singleNodeUri && ( +
+
+

+ SUBSCRIPTION URL +

+

{genericUrl}

+

+ {title} · {description} +

+
+ +
+
+ ); +} diff --git a/src/app/(user)/subscriptions/page.tsx b/src/app/(user)/subscriptions/page.tsx index 326c76d..f12664b 100644 --- a/src/app/(user)/subscriptions/page.tsx +++ b/src/app/(user)/subscriptions/page.tsx @@ -18,7 +18,7 @@ import { getAggregateSubscriptionToken } from "@/services/subscription"; export const metadata: Metadata = { title: "我的订阅", - description: "管理活跃订阅并查看历史记录。", + description: "管理活跃订阅,并按 Clash、通用订阅等格式导入客户端。", }; export default async function SubscriptionsPage() { @@ -39,7 +39,7 @@ export default async function SubscriptionsPage() { row.trafficLimit == null); + const userInfo = buildSubscriptionUserInfo({ + upload: 0, + download: statsRows.reduce((sum, row) => sum + row.trafficUsed, BigInt(0)), + total: hasUnlimitedTraffic + ? null + : statsRows.reduce((sum, row) => sum + (row.trafficLimit ?? BigInt(0)), BigInt(0)), + expire: statsRows.reduce((earliest, row) => { + if (!earliest || row.endDate < earliest) return row.endDate; + return earliest; + }, null), + }); + const headers = new Headers({ + "Content-Type": getSubscriptionContentType(format), + "Content-Disposition": `attachment; filename="${getSubscriptionFilename("jboard-all-sub", format)}"`, + "Cache-Control": "no-store", + "profile-update-interval": "12", + "profile-web-page-url": `${url.origin}/subscriptions`, + }); + if (userInfo) headers.set("Subscription-Userinfo", userInfo); + return new Response(content, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Content-Disposition": 'attachment; filename="jboard-all-sub.txt"', - "Cache-Control": "no-store", - }, + headers, }); } diff --git a/src/components/shared/copy-button.tsx b/src/components/shared/copy-button.tsx index 30943ed..da080af 100644 --- a/src/components/shared/copy-button.tsx +++ b/src/components/shared/copy-button.tsx @@ -5,7 +5,7 @@ import { Check, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; -export function CopyButton({ text }: { text: string }) { +export function CopyButton({ text, label = "复制" }: { text: string; label?: string }) { const [copied, setCopied] = useState(false); return ( @@ -20,7 +20,7 @@ export function CopyButton({ text }: { text: string }) { }} > {copied ? : } - {copied ? "已复制" : "复制"} + {copied ? "已复制" : label} ); } diff --git a/src/services/subscription.ts b/src/services/subscription.ts index b614a2e..1243e4f 100644 --- a/src/services/subscription.ts +++ b/src/services/subscription.ts @@ -37,6 +37,66 @@ interface LinkTarget { remark?: string; } +export type SubscriptionOutputFormat = "base64" | "uri" | "clash"; + +export interface SubscriptionTrafficStats { + upload?: bigint | number | null; + download?: bigint | number | null; + total?: bigint | number | null; + expire?: Date | null; +} + +const CLASH_FORMAT_ALIASES = new Set(["clash", "clash-meta", "mihomo", "yaml", "yml"]); +const URI_FORMAT_ALIASES = new Set(["uri", "raw", "plain", "text"]); +const BASE64_FORMAT_ALIASES = new Set(["base64", "v2ray", "generic"]); + +function normalizeSubscriptionFormat(raw: string | null): SubscriptionOutputFormat | null { + if (!raw) return null; + const normalized = raw.trim().toLowerCase(); + if (CLASH_FORMAT_ALIASES.has(normalized)) return "clash"; + if (URI_FORMAT_ALIASES.has(normalized)) return "uri"; + if (BASE64_FORMAT_ALIASES.has(normalized)) return "base64"; + return null; +} + +function isClashLikeUserAgent(userAgent: string | null | undefined) { + if (!userAgent) return false; + return /clash|mihomo|verge|stash|flclash|nyanpasu|openclash/i.test(userAgent); +} + +export function resolveSubscriptionFormat( + searchParams: URLSearchParams, + userAgent?: string | null, +): SubscriptionOutputFormat { + return normalizeSubscriptionFormat(searchParams.get("format") ?? searchParams.get("target")) + ?? (isClashLikeUserAgent(userAgent) ? "clash" : "base64"); +} + +export function getSubscriptionContentType(format: SubscriptionOutputFormat) { + return format === "clash" ? "text/yaml; charset=utf-8" : "text/plain; charset=utf-8"; +} + +export function getSubscriptionFilename(baseName: string, format: SubscriptionOutputFormat) { + return `${baseName}.${format === "clash" ? "yaml" : "txt"}`; +} + +function bigintHeaderValue(value: bigint | number | null | undefined) { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.floor(value)).toString(); + return "0"; +} + +export function buildSubscriptionUserInfo(stats: SubscriptionTrafficStats | null | undefined) { + if (!stats) return null; + const expire = stats.expire ? Math.floor(stats.expire.getTime() / 1000) : 0; + return [ + `upload=${bigintHeaderValue(stats.upload)}`, + `download=${bigintHeaderValue(stats.download)}`, + `total=${bigintHeaderValue(stats.total)}`, + `expire=${expire}`, + ].join("; "); +} + function getAggregateSubscriptionSecret() { const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL; if (!secret) { @@ -439,6 +499,55 @@ function appendQueryAndHash(base: string, params: URLSearchParams, label: string return `${base}${query ? `?${query}` : ""}#${encodeURIComponent(label)}`; } +function stripUndefined(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== "")) as T; +} + +function yamlQuote(value: string) { + return JSON.stringify(value); +} + +function toYaml(value: unknown, indent = 0): string { + const pad = " ".repeat(indent); + if (Array.isArray(value)) { + if (value.length === 0) return `${pad}[]`; + return value.map((item) => { + if (item && typeof item === "object" && !Array.isArray(item)) { + const record = item as JsonRecord; + const entries = Object.entries(record); + if (entries.length === 0) return `${pad}- {}`; + const [firstKey, firstValue] = entries[0]; + const firstLine = `${pad}- ${firstKey}: ${yamlInline(firstValue)}`; + const rest = entries.slice(1).map(([key, child]) => `${pad} ${key}: ${yamlBlock(child, indent + 2)}`); + return [firstLine, ...rest].join("\n"); + } + return `${pad}- ${yamlInline(item)}`; + }).join("\n"); + } + + const record = asRecord(value); + if (!record) return `${pad}${yamlInline(value)}`; + const entries = Object.entries(record); + if (entries.length === 0) return `${pad}{}`; + return entries.map(([key, child]) => `${pad}${key}: ${yamlBlock(child, indent)}`).join("\n"); +} + +function yamlBlock(value: unknown, indent: number) { + if (value && typeof value === "object") { + const nested = toYaml(value, indent + 2); + return `\n${nested}`; + } + return yamlInline(value); +} + +function yamlInline(value: unknown): string { + if (typeof value === "string") return yamlQuote(value); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "boolean") return value ? "true" : "false"; + if (value == null) return "null"; + return yamlQuote(String(value)); +} + function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] { const stream = getStream(nodeClient); const proxies: LinkTarget[] = []; @@ -465,6 +574,217 @@ function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] { return [{ address: getServerAddress(nodeClient), port: nodeClient.inbound.port }]; } +function getStreamSecurity(stream: JsonRecord, target?: LinkTarget) { + return target?.securityOverride && target.securityOverride !== "same" + ? target.securityOverride + : (asString(stream.security) ?? "none"); +} + +function getTlsSettings(stream: JsonRecord) { + const tls = asRecord(stream.tlsSettings); + const nested = asRecord(tls?.settings); + return { tls, nested }; +} + +function getRealitySettings(stream: JsonRecord) { + const reality = asRecord(stream.realitySettings); + const nested = asRecord(reality?.settings); + return { reality, nested }; +} + +function applyClashTls(proxy: JsonRecord, stream: JsonRecord, security: string) { + if (security !== "tls" && security !== "reality") return; + + proxy.tls = true; + if (security === "tls") { + const { tls, nested } = getTlsSettings(stream); + const serverName = asString(tls?.serverName) ?? asString(nested?.serverName); + const fingerprint = asString(nested?.fingerprint) ?? asString(tls?.fingerprint); + const alpn = stringList(tls?.alpn); + if (serverName) proxy.servername = serverName; + if (fingerprint) proxy["client-fingerprint"] = fingerprint; + if (alpn.length > 0) proxy.alpn = alpn; + if (asBoolean(nested?.allowInsecure) || asBoolean(tls?.allowInsecure)) { + proxy["skip-cert-verify"] = true; + } + return; + } + + const { reality, nested } = getRealitySettings(stream); + const serverName = firstString(reality?.serverNames) ?? asString(reality?.serverName); + const publicKey = asString(nested?.publicKey) ?? asString(reality?.publicKey); + const shortId = firstString(reality?.shortIds) ?? asString(reality?.shortId); + const fingerprint = asString(nested?.fingerprint) ?? asString(reality?.fingerprint); + const spiderX = asString(nested?.spiderX) ?? asString(reality?.spiderX); + if (serverName) proxy.servername = serverName; + if (fingerprint) proxy["client-fingerprint"] = fingerprint; + proxy["reality-opts"] = stripUndefined({ + "public-key": publicKey, + "short-id": shortId, + "spider-x": spiderX, + }); +} + +function getPathAndHost(settings: unknown) { + const record = asRecord(settings); + if (!record) return { path: null, host: null }; + return { + path: asString(record.path), + host: asString(record.host) ?? getHeaderValue(record.headers, "host"), + }; +} + +function applyClashTransport(proxy: JsonRecord, stream: JsonRecord) { + const network = asString(stream.network) ?? "tcp"; + if (network === "tcp") { + const tcp = asRecord(stream.tcpSettings); + const header = asRecord(tcp?.header); + if (asString(header?.type) !== "http") return; + const request = asRecord(header?.request); + proxy.network = "http"; + proxy["http-opts"] = stripUndefined({ + path: stringList(request?.path), + headers: stripUndefined({ Host: stringList(asRecord(request?.headers)?.Host ?? asRecord(request?.headers)?.host) }), + }); + return; + } + + proxy.network = network; + if (network === "ws") { + const { path, host } = getPathAndHost(stream.wsSettings); + proxy["ws-opts"] = stripUndefined({ + path, + headers: host ? { Host: host } : undefined, + }); + } else if (network === "grpc") { + const grpc = asRecord(stream.grpcSettings); + proxy["grpc-opts"] = stripUndefined({ + "grpc-service-name": asString(grpc?.serviceName), + }); + } else if (network === "httpupgrade") { + const { path, host } = getPathAndHost(stream.httpupgradeSettings); + proxy["httpupgrade-opts"] = stripUndefined({ + path, + headers: host ? { Host: host } : undefined, + }); + } else if (network === "xhttp") { + const { path, host } = getPathAndHost(stream.xhttpSettings); + proxy["xhttp-opts"] = stripUndefined({ + path, + headers: host ? { Host: host } : undefined, + }); + } +} + +function buildClashProxy(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) { + const protocol = nodeClient.inbound.protocol.toLowerCase(); + const stream = getStream(nodeClient); + const settings = getSettings(nodeClient); + const security = getStreamSecurity(stream, target); + const base: JsonRecord = { + name: getDisplayName(nodeClient, target), + server: target.address, + port: target.port, + udp: true, + }; + + let proxy: JsonRecord | null = null; + if (protocol === "vmess") { + proxy = { + ...base, + type: "vmess", + uuid: asString(client?.id) ?? nodeClient.uuid, + alterId: asNumber(settings.alterId) ?? 0, + cipher: getVmessSecurity(settings, client), + }; + } else if (protocol === "vless") { + proxy = stripUndefined({ + ...base, + type: "vless", + uuid: asString(client?.id) ?? nodeClient.uuid, + flow: getVlessFlow(settings, client), + }); + } else if (protocol === "trojan") { + proxy = { + ...base, + type: "trojan", + password: asString(client?.password) ?? nodeClient.uuid, + }; + } else if (protocol === "shadowsocks") { + const method = asString(settings.method) ?? asString(client?.method) ?? "chacha20-ietf-poly1305"; + const inboundPassword = asString(settings.password) ?? asString(settings.serverKey); + const clientPassword = asString(client?.password) ?? nodeClient.uuid; + proxy = { + ...base, + type: "ss", + cipher: method, + password: method.startsWith("2022-") && inboundPassword ? `${inboundPassword}:${clientPassword}` : clientPassword, + }; + } else if (protocol === "hysteria2") { + const obfsPassword = findSalamanderPassword(stream); + proxy = stripUndefined({ + ...base, + type: asNumber(settings.version) === 1 ? "hysteria" : "hysteria2", + password: asString(client?.auth) ?? nodeClient.uuid, + obfs: obfsPassword ? "salamander" : undefined, + "obfs-password": obfsPassword, + }); + } + + if (!proxy) return null; + applyClashTransport(proxy, stream); + applyClashTls(proxy, stream, security); + return stripUndefined(proxy); +} + +function dedupeProxyNames(proxies: JsonRecord[]) { + const seen = new Map(); + return proxies.map((proxy) => { + const name = asString(proxy.name) ?? "J-Board"; + const count = seen.get(name) ?? 0; + seen.set(name, count + 1); + return count === 0 ? proxy : { ...proxy, name: `${name} ${count + 1}` }; + }); +} + +export function buildClashSubscriptionYaml(nodeClients: ProxyNodeContext[]): string { + const proxies = dedupeProxyNames( + nodeClients.flatMap((nodeClient) => { + const client = findClient(nodeClient); + return getTargets(nodeClient) + .map((target) => buildClashProxy(nodeClient, target, client)) + .filter((item): item is JsonRecord => item != null); + }), + ); + const proxyNames = proxies.map((proxy) => asString(proxy.name) ?? "J-Board"); + const selectableNames = proxyNames.length > 0 ? proxyNames : ["DIRECT"]; + const config = { + "mixed-port": 7890, + "allow-lan": false, + mode: "rule", + "log-level": "info", + ipv6: true, + proxies, + "proxy-groups": [ + { + name: "节点选择", + type: "select", + proxies: ["自动选择", ...selectableNames, "DIRECT"], + }, + { + name: "自动选择", + type: "url-test", + proxies: selectableNames, + url: "https://www.gstatic.com/generate_204", + interval: 300, + }, + ], + rules: ["MATCH,节点选择"], + }; + + return `${toYaml(config)}\n`; +} + function getVmessSecurity(settings: JsonRecord, client: PanelClient | null) { return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto"; } @@ -627,13 +947,35 @@ export async function generateSingleNodeUri(subscriptionId: string): Promise { - const uri = await generateSingleNodeUri(subscriptionId); +function encodeSubscriptionContent(uri: string, format: SubscriptionOutputFormat) { if (!uri) return ""; - return Buffer.from(uri).toString("base64"); + return format === "base64" ? Buffer.from(uri).toString("base64") : uri; } -export async function generateAggregateSubscriptionContent(userId: string): Promise { +export async function generateSubscriptionContent( + subscriptionId: string, + format: SubscriptionOutputFormat = "base64", +): Promise { + const sub = await prisma.userSubscription.findUniqueOrThrow({ + where: { id: subscriptionId }, + include: { + nodeClient: { + include: { + inbound: { include: { server: true } }, + }, + }, + }, + }); + + if (sub.status !== "ACTIVE" || !sub.nodeClient) return ""; + if (format === "clash") return buildClashSubscriptionYaml([sub.nodeClient]); + return encodeSubscriptionContent(buildSingleNodeUri(sub.nodeClient), format); +} + +export async function generateAggregateSubscriptionContent( + userId: string, + format: SubscriptionOutputFormat = "base64", +): Promise { const subscriptions = await prisma.userSubscription.findMany({ where: { userId, @@ -652,10 +994,16 @@ export async function generateAggregateSubscriptionContent(userId: string): Prom orderBy: [{ endDate: "asc" }, { createdAt: "asc" }], }); - const content = subscriptions - .map((subscription) => subscription.nodeClient ? buildSingleNodeUri(subscription.nodeClient) : "") + const nodeClients = subscriptions + .map((subscription) => subscription.nodeClient) + .filter((nodeClient): nodeClient is NonNullable => nodeClient != null); + + if (format === "clash") return buildClashSubscriptionYaml(nodeClients); + + const content = nodeClients + .map((nodeClient) => buildSingleNodeUri(nodeClient)) .filter(Boolean) .join("\n"); - return content ? Buffer.from(content).toString("base64") : ""; + return encodeSubscriptionContent(content, format); }