mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: support clash subscription imports
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { zhCN } from "date-fns/locale";
|
import { zhCN } from "date-fns/locale";
|
||||||
import { CalendarClock, Gauge, Layers3, Link2, Sparkles } from "lucide-react";
|
import { CalendarClock, Gauge, Layers3, Sparkles } from "lucide-react";
|
||||||
import { CopyButton } from "@/components/shared/copy-button";
|
|
||||||
import { QrPreview } from "@/components/shared/qr-preview";
|
import { QrPreview } from "@/components/shared/qr-preview";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { formatBytes } from "@/lib/utils";
|
import { formatBytes } from "@/lib/utils";
|
||||||
|
import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions";
|
||||||
|
|
||||||
interface AggregateSubscriptionCardProps {
|
interface AggregateSubscriptionCardProps {
|
||||||
subscriptionUrl: string;
|
subscriptionUrl: string;
|
||||||
@@ -24,6 +24,7 @@ export function AggregateSubscriptionCard({
|
|||||||
const percent = totalLimit
|
const percent = totalLimit
|
||||||
? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100))
|
? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
|
const clashUrl = withSubscriptionFormat(subscriptionUrl, "clash");
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
|
<SubscriptionImportActions genericUrl={subscriptionUrl} clashUrl={clashUrl} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
|
||||||
@@ -94,7 +85,7 @@ export function AggregateSubscriptionCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:sticky lg:top-24">
|
<div className="lg:sticky lg:top-24">
|
||||||
<QrPreview label="总订阅二维码" value={subscriptionUrl} alt="总订阅链接二维码" />
|
<QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅链接二维码" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Gauge, Link2, QrCode } from "lucide-react";
|
import { Gauge, Link2, QrCode } from "lucide-react";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { CopyButton } from "@/components/shared/copy-button";
|
|
||||||
import { QrPreview } from "@/components/shared/qr-preview";
|
import { QrPreview } from "@/components/shared/qr-preview";
|
||||||
import { formatBytes } from "@/lib/utils";
|
import { formatBytes } from "@/lib/utils";
|
||||||
import { buildSingleNodeUri } from "@/services/subscription";
|
import { buildSingleNodeUri } from "@/services/subscription";
|
||||||
import type { SubscriptionRecord } from "../subscriptions-types";
|
import type { SubscriptionRecord } from "../subscriptions-types";
|
||||||
|
import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions";
|
||||||
|
|
||||||
interface ProxySubscriptionDetailsProps {
|
interface ProxySubscriptionDetailsProps {
|
||||||
sub: SubscriptionRecord;
|
sub: SubscriptionRecord;
|
||||||
@@ -18,6 +18,7 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
|
|||||||
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
|
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
|
||||||
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||||
const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`;
|
const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`;
|
||||||
|
const clashUrl = withSubscriptionFormat(subUrl, "clash");
|
||||||
const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : "";
|
const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,18 +46,20 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
|
|||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<Link2 className="size-4 text-primary" /> 导入信息
|
<Link2 className="size-4 text-primary" /> 导入信息
|
||||||
</div>
|
</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="mt-3">
|
||||||
<div className="min-w-0">
|
<SubscriptionImportActions
|
||||||
<p className="text-xs font-semibold tracking-[0.14em] text-muted-foreground">SUBSCRIPTION URL</p>
|
genericUrl={subUrl}
|
||||||
<p className="mt-1 truncate font-mono text-xs text-foreground/82">{subUrl}</p>
|
clashUrl={clashUrl}
|
||||||
</div>
|
title="单节点导入"
|
||||||
<CopyButton text={subUrl} />
|
description="适合只导入当前节点;多节点日常使用建议复制总订阅链接。"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
|
||||||
<QrCode className="size-3.5" /> 扫码导入
|
<QrCode className="size-3.5" /> 扫码导入
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
<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 && (
|
{singleNodeUri && (
|
||||||
<QrPreview
|
<QrPreview
|
||||||
label="单节点 URI 二维码"
|
label="单节点 URI 二维码"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { getAggregateSubscriptionToken } from "@/services/subscription";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "我的订阅",
|
title: "我的订阅",
|
||||||
description: "管理活跃订阅并查看历史记录。",
|
description: "管理活跃订阅,并按 Clash、通用订阅等格式导入客户端。",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SubscriptionsPage() {
|
export default async function SubscriptionsPage() {
|
||||||
@@ -39,7 +39,7 @@ export default async function SubscriptionsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="订阅管理"
|
eyebrow="订阅管理"
|
||||||
title="我的订阅"
|
title="我的订阅"
|
||||||
description="总订阅链接负责导入全部代理节点;单个节点卡片只保留状态和快捷操作。"
|
description="总订阅链接负责导入全部代理节点;Clash 可一键导入 YAML,其他客户端继续使用通用订阅。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SubscriptionMetrics
|
<SubscriptionMetrics
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { generateSubscriptionContent } from "@/services/subscription";
|
import {
|
||||||
|
buildSubscriptionUserInfo,
|
||||||
|
generateSubscriptionContent,
|
||||||
|
getSubscriptionContentType,
|
||||||
|
getSubscriptionFilename,
|
||||||
|
resolveSubscriptionFormat,
|
||||||
|
} from "@/services/subscription";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -26,11 +32,24 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Subscription inactive" }, { status: 403 });
|
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, {
|
return new Response(content, {
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="jboard-sub.txt"`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
|
buildSubscriptionUserInfo,
|
||||||
generateAggregateSubscriptionContent,
|
generateAggregateSubscriptionContent,
|
||||||
|
getSubscriptionContentType,
|
||||||
|
getSubscriptionFilename,
|
||||||
|
resolveSubscriptionFormat,
|
||||||
verifyAggregateSubscriptionToken,
|
verifyAggregateSubscriptionToken,
|
||||||
} from "@/services/subscription";
|
} from "@/services/subscription";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const url = new URL(req.url);
|
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 });
|
return NextResponse.json({ error: "Invalid subscription token" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await generateAggregateSubscriptionContent(userId);
|
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
||||||
return new Response(content, {
|
const activeProxyWhere = {
|
||||||
headers: {
|
userId,
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
status: "ACTIVE" as const,
|
||||||
"Content-Disposition": 'attachment; filename="jboard-all-sub.txt"',
|
endDate: { gt: new Date() },
|
||||||
"Cache-Control": "no-store",
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Check, Copy } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function CopyButton({ text }: { text: string }) {
|
export function CopyButton({ text, label = "复制" }: { text: string; label?: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +20,7 @@ export function CopyButton({ text }: { text: string }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||||
{copied ? "已复制" : "复制"}
|
{copied ? "已复制" : label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,66 @@ interface LinkTarget {
|
|||||||
remark?: string;
|
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() {
|
function getAggregateSubscriptionSecret() {
|
||||||
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL;
|
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
@@ -439,6 +499,55 @@ function appendQueryAndHash(base: string, params: URLSearchParams, label: string
|
|||||||
return `${base}${query ? `?${query}` : ""}#${encodeURIComponent(label)}`;
|
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[] {
|
function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] {
|
||||||
const stream = getStream(nodeClient);
|
const stream = getStream(nodeClient);
|
||||||
const proxies: LinkTarget[] = [];
|
const proxies: LinkTarget[] = [];
|
||||||
@@ -465,6 +574,217 @@ function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] {
|
|||||||
return [{ address: getServerAddress(nodeClient), port: nodeClient.inbound.port }];
|
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) {
|
function getVmessSecurity(settings: JsonRecord, client: PanelClient | null) {
|
||||||
return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto";
|
return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto";
|
||||||
}
|
}
|
||||||
@@ -627,13 +947,35 @@ export async function generateSingleNodeUri(subscriptionId: string): Promise<str
|
|||||||
return buildSingleNodeUri(sub.nodeClient);
|
return buildSingleNodeUri(sub.nodeClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSubscriptionContent(subscriptionId: string): Promise<string> {
|
function encodeSubscriptionContent(uri: string, format: SubscriptionOutputFormat) {
|
||||||
const uri = await generateSingleNodeUri(subscriptionId);
|
|
||||||
if (!uri) return "";
|
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({
|
const subscriptions = await prisma.userSubscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -652,10 +994,16 @@ export async function generateAggregateSubscriptionContent(userId: string): Prom
|
|||||||
orderBy: [{ endDate: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ endDate: "asc" }, { createdAt: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = subscriptions
|
const nodeClients = subscriptions
|
||||||
.map((subscription) => subscription.nodeClient ? buildSingleNodeUri(subscription.nodeClient) : "")
|
.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)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
return content ? Buffer.from(content).toString("base64") : "";
|
return encodeSubscriptionContent(content, format);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user