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);
}