feat: support clash subscription imports

This commit is contained in:
JetSprow
2026-04-29 14:49:18 +10:00
parent 68eac100f2
commit d7681240bb
8 changed files with 517 additions and 45 deletions

View File

@@ -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 (
<section className="relative overflow-hidden rounded-2xl border border-primary/15 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.16),transparent_34%),linear-gradient(135deg,hsl(var(--card)),hsl(var(--muted)/0.35))] p-5 shadow-sm sm:p-6">
@@ -44,17 +45,7 @@ export function AggregateSubscriptionCard({
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Link2 className="size-3.5" /> SUBSCRIPTION URL
</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/80">{subscriptionUrl}</p>
</div>
<CopyButton text={subscriptionUrl} />
</div>
</div>
<SubscriptionImportActions genericUrl={subscriptionUrl} clashUrl={clashUrl} />
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
@@ -94,7 +85,7 @@ export function AggregateSubscriptionCard({
</div>
<div className="lg:sticky lg:top-24">
<QrPreview label="订阅二维码" value={subscriptionUrl} alt="订阅链接二维码" />
<QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅链接二维码" />
</div>
</div>
</section>

View File

@@ -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
<div className="flex items-center gap-2 text-sm font-semibold">
<Link2 className="size-4 text-primary" />
</div>
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-border/40 bg-background/45 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold tracking-[0.14em] text-muted-foreground">SUBSCRIPTION URL</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/82">{subUrl}</p>
</div>
<CopyButton text={subUrl} />
<div className="mt-3">
<SubscriptionImportActions
genericUrl={subUrl}
clashUrl={clashUrl}
title="单节点导入"
description="适合只导入当前节点;多节点日常使用建议复制总订阅链接。"
compact
/>
</div>
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<QrCode className="size-3.5" />
</div>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<QrPreview label="订阅 URL 二维码" value={subUrl} alt="订阅 URL 二维码" />
<QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅二维码" />
{singleNodeUri && (
<QrPreview
label="单节点 URI 二维码"

View File

@@ -0,0 +1,71 @@
import { Download, ExternalLink, FileCode2, Link2 } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface SubscriptionImportActionsProps {
genericUrl: string;
clashUrl: string;
title?: string;
description?: string;
compact?: boolean;
}
function buildClashImportUrl(url: string) {
return `clash://install-config?url=${encodeURIComponent(url)}`;
}
export function withSubscriptionFormat(url: string, format: "base64" | "uri" | "clash") {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}format=${format}`;
}
export function SubscriptionImportActions({
genericUrl,
clashUrl,
title = "客户端导入",
description = "Clash 使用 YAML 订阅;其他客户端可继续使用通用 Base64 链接。",
compact = false,
}: SubscriptionImportActionsProps) {
const clashImportUrl = buildClashImportUrl(clashUrl);
return (
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
<div className={cn("flex flex-col gap-3", compact ? "" : "lg:flex-row lg:items-center lg:justify-between")}>
<div className="min-w-0 space-y-1">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Link2 className="size-3.5" /> SUBSCRIPTION URL
</p>
<p className="truncate font-mono text-xs text-foreground/80">{genericUrl}</p>
<p className="text-xs leading-5 text-muted-foreground">
<span className="font-semibold text-foreground">{title}</span> · {description}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:flex lg:shrink-0 lg:flex-wrap lg:justify-end">
<a
href={clashImportUrl}
className={cn(buttonVariants({ size: "sm" }), "sm:col-span-2 lg:col-span-1")}
>
<ExternalLink className="size-3.5" /> Clash
</a>
<CopyButton text={clashUrl} label="复制 Clash" />
<CopyButton text={genericUrl} label="复制通用" />
<a
href={clashUrl}
download
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<FileCode2 className="size-3.5" /> YAML
</a>
<a
href={genericUrl}
download
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
<Download className="size-3.5" />
</a>
</div>
</div>
</div>
);
}

View File

@@ -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() {
<PageHeader
eyebrow="订阅管理"
title="我的订阅"
description="总订阅链接负责导入全部代理节点;单个节点卡片只保留状态和快捷操作。"
description="总订阅链接负责导入全部代理节点;Clash 可一键导入 YAML其他客户端继续使用通用订阅。"
/>
<SubscriptionMetrics

View File

@@ -1,5 +1,11 @@
import { NextResponse } from "next/server";
import { generateSubscriptionContent } from "@/services/subscription";
import {
buildSubscriptionUserInfo,
generateSubscriptionContent,
getSubscriptionContentType,
getSubscriptionFilename,
resolveSubscriptionFormat,
} from "@/services/subscription";
import { prisma } from "@/lib/prisma";
export async function GET(
@@ -26,11 +32,24 @@ export async function GET(
return NextResponse.json({ error: "Subscription inactive" }, { status: 403 });
}
const content = await generateSubscriptionContent(id);
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
const content = await generateSubscriptionContent(id, format);
const userInfo = buildSubscriptionUserInfo({
upload: 0,
download: sub.trafficUsed,
total: sub.trafficLimit,
expire: sub.endDate,
});
const headers = new Headers({
"Content-Type": getSubscriptionContentType(format),
"Content-Disposition": `attachment; filename="${getSubscriptionFilename("jboard-sub", format)}"`,
"Cache-Control": "no-store",
"profile-update-interval": "12",
"profile-web-page-url": `${url.origin}/subscriptions/${id}`,
});
if (userInfo) headers.set("Subscription-Userinfo", userInfo);
return new Response(content, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="jboard-sub.txt"`,
},
headers,
});
}

View File

@@ -1,8 +1,13 @@
import { NextResponse } from "next/server";
import {
buildSubscriptionUserInfo,
generateAggregateSubscriptionContent,
getSubscriptionContentType,
getSubscriptionFilename,
resolveSubscriptionFormat,
verifyAggregateSubscriptionToken,
} from "@/services/subscription";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const url = new URL(req.url);
@@ -17,12 +22,47 @@ export async function GET(req: Request) {
return NextResponse.json({ error: "Invalid subscription token" }, { status: 401 });
}
const content = await generateAggregateSubscriptionContent(userId);
return new Response(content, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": 'attachment; filename="jboard-all-sub.txt"',
"Cache-Control": "no-store",
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
const activeProxyWhere = {
userId,
status: "ACTIVE" as const,
endDate: { gt: new Date() },
plan: { type: "PROXY" as const },
nodeClient: { isNot: null },
};
const [content, statsRows] = await Promise.all([
generateAggregateSubscriptionContent(userId, format),
prisma.userSubscription.findMany({
where: activeProxyWhere,
select: {
trafficUsed: true,
trafficLimit: true,
endDate: true,
},
}),
]);
const hasUnlimitedTraffic = statsRows.some((row) => 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<Date | null>((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,
});
}

View File

@@ -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 ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "已复制" : "复制"}
{copied ? "已复制" : label}
</Button>
);
}

View File

@@ -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<T extends JsonRecord>(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<string, number>();
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<str
return buildSingleNodeUri(sub.nodeClient);
}
export async function generateSubscriptionContent(subscriptionId: string): Promise<string> {
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<string> {
export async function generateSubscriptionContent(
subscriptionId: string,
format: SubscriptionOutputFormat = "base64",
): Promise<string> {
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<string> {
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<typeof nodeClient> => 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);
}