Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions local/app/api/subscriptions/[id]/config.yaml/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { apiError } from "@local/lib/http";
import { generateSubscriptionYaml } from "@local/lib/subscription-service";
import { buildSubscriptionResponseHeaders } from "@subboost/server-core/subscription";

type RouteContext = {
params: Promise<{ id: string }>;
};

export async function GET(_request: Request, { params }: RouteContext) {
const { id: token } = await params;
const yaml = await generateSubscriptionYaml(token);
if (!yaml) return apiError("Subscription YAML not found.", "NOT_FOUND", 404);
return new Response(yaml, {
headers: {
"Content-Type": "text/yaml; charset=utf-8",
"Cache-Control": "no-store",
},
const result = await generateSubscriptionYaml(token);
if (!result) return apiError("Subscription YAML not found.", "NOT_FOUND", 404);
return new Response(result.yaml, {
headers: buildSubscriptionResponseHeaders(result.name, result.subscriptionInfo, {
cacheControl: "no-store",
cacheExpirySeconds: result.cacheExpirySeconds,
autoUpdateIntervalSeconds: result.autoUpdateIntervalSeconds,
isAdmin: result.isAdmin,
}),
});
}
11 changes: 11 additions & 0 deletions local/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import { readJsonResponse } from "@subboost/ui/product/client-response";
import type { RefreshSubscriptionResponse, Subscription } from "@subboost/ui/dashboard/dashboard-types";
import { LOCAL_AUTO_UPDATE_POLICY } from "@local/lib/auto-update-policy";

function resolveLocalDashboardDownloadUrl(subscription: Subscription): string {
try {
const url = new URL(subscription.subscriptionUrl, window.location.href);
if (url.pathname.includes("/api/subscriptions/")) {
return `${window.location.origin}${url.pathname}${url.search}`;
}
} catch {}
return subscription.subscriptionUrl;
}

const localDashboardAdapter: DashboardSurfaceAdapter = {
loginHref: "/login",
newSubscriptionHref: "/?newSubscription=1",
Expand Down Expand Up @@ -39,6 +49,7 @@ const localDashboardAdapter: DashboardSurfaceAdapter = {
});
await readJsonResponse<{ error?: string }>(response, "保存失败");
},
resolveDownloadUrl: resolveLocalDashboardDownloadUrl,
};

export default function DashboardPage() {
Expand Down
11 changes: 11 additions & 0 deletions local/app/local-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,22 @@ describe("local app pages and adapters", () => {

renderToStaticMarkup(React.createElement(DashboardPage));
const adapter = mocks.dashboardAdapter;
vi.stubGlobal("window", {
location: {
href: "http://23.80.90.37:31401/dashboard",
origin: "http://23.80.90.37:31401",
},
});

await expect(adapter.fetchSubscriptions()).resolves.toEqual([{ id: "sub-1" }]);
await expect(adapter.deleteSubscription("sub 1")).resolves.toBeUndefined();
await expect(adapter.refreshSubscription("sub 1")).resolves.toEqual({ ok: true });
await expect(adapter.updateSubscriptionSettings("sub 1", { name: "Sub" })).resolves.toBeUndefined();
expect(
adapter.resolveDownloadUrl({
subscriptionUrl: "http://localhost:3001/api/subscriptions/token-1/config.yaml",
})
).toBe("http://23.80.90.37:31401/api/subscriptions/token-1/config.yaml");
expect(adapter.autoUpdateIntervalPolicy).toEqual({
defaultHours: 12,
minHours: 0.1,
Expand Down
11 changes: 9 additions & 2 deletions local/src/lib/subscription-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,14 @@ describe("local subscription service", () => {
});

it("generates YAML and updates access time when a subscription has nodes or proxy providers", async () => {
await expect(generateSubscriptionYaml("token-1")).resolves.toBe("mixed-port: 7890\n");
await expect(generateSubscriptionYaml("token-1")).resolves.toMatchObject({
yaml: "mixed-port: 7890\n",
name: "Saved",
subscriptionInfo: { upload: 2048, total: 4096 },
cacheExpirySeconds: 3600,
autoUpdateIntervalSeconds: 86400,
isAdmin: true,
});
expect(mocks.buildGenerateOptionsFromConfig).toHaveBeenCalledWith(
expect.objectContaining({ sources: expect.any(Array) }),
expect.objectContaining({ nodes: [expect.objectContaining({ name: "Node" })], proxyProviders: null })
Expand All @@ -487,6 +494,6 @@ describe("local subscription service", () => {
row({ encryptedNodes: JSON.stringify([]), encryptedConfig: JSON.stringify({ proxyProviders: { provider: {} } }) })
);
mocks.buildProxyProvidersFromConfig.mockReturnValueOnce({ provider: { url: "https://example.com/provider.yaml" } });
await expect(generateSubscriptionYaml("provider-only")).resolves.toBe("mixed-port: 7890\n");
await expect(generateSubscriptionYaml("provider-only")).resolves.toMatchObject({ yaml: "mixed-port: 7890\n" });
});
});
21 changes: 19 additions & 2 deletions local/src/lib/subscription-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { generateClashYaml } from "@subboost/core/generator";
import { buildGenerateOptionsFromConfig, getEffectiveTestOptions } from "@subboost/core/subscription/config-utils";
import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers";
import type { SubscriptionResponseInfo } from "@subboost/core/subscription/subscription-response-info";
import type { ParsedNode } from "@subboost/core/types/node";
import {
buildManualRefreshFailureResponse,
Expand Down Expand Up @@ -87,6 +88,15 @@ export type SubscriptionDetail = SubscriptionSummary & {
subscriptionInfo: Record<string, unknown>;
};

export type GeneratedSubscriptionYaml = {
yaml: string;
name: string;
subscriptionInfo: SubscriptionResponseInfo;
cacheExpirySeconds: number;
autoUpdateIntervalSeconds: number | null;
isAdmin: boolean;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
Expand Down Expand Up @@ -357,7 +367,7 @@ export async function refreshSubscription(ownerId: string, id: string) {
};
}

export async function generateSubscriptionYaml(token: string): Promise<string | null> {
export async function generateSubscriptionYaml(token: string): Promise<GeneratedSubscriptionYaml | null> {
const row = await prisma.subscription.findUnique({ where: { token }, include: { autoUpdateState: true } });
if (!row) return null;
const secrets = readSubscriptionSecrets(row);
Expand All @@ -371,5 +381,12 @@ export async function generateSubscriptionYaml(token: string): Promise<string |
})
);
await prisma.subscription.update({ where: { id: row.id }, data: { lastAccessedAt: new Date() } });
return yaml;
return {
yaml,
name: row.name,
subscriptionInfo: secrets.subscriptionInfo,
cacheExpirySeconds: CACHE_TTL_SECONDS,
autoUpdateIntervalSeconds: row.autoUpdateInterval,
isAdmin: true,
};
}
20 changes: 19 additions & 1 deletion local/test/subscription-route-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,19 @@ beforeEach(() => {
vi.mocked(getCurrentAdmin).mockResolvedValue(admin);
vi.mocked(createSubscription).mockResolvedValue(subscription as never);
vi.mocked(deleteSubscription).mockResolvedValue(true);
vi.mocked(generateSubscriptionYaml).mockResolvedValue("mixed-port: 7890\n");
vi.mocked(generateSubscriptionYaml).mockResolvedValue({
yaml: "mixed-port: 7890\n",
name: "Main",
subscriptionInfo: {
upload: 64,
download: 128,
total: 1024,
expire: 1781635200,
},
cacheExpirySeconds: 3600,
autoUpdateIntervalSeconds: 86400,
isAdmin: true,
} as never);
vi.mocked(getSubscription).mockResolvedValue(subscription as never);
vi.mocked(listSubscriptions).mockResolvedValue([subscription] as never);
vi.mocked(refreshSubscription).mockResolvedValue({
Expand Down Expand Up @@ -138,6 +150,12 @@ describe("local subscription routes", () => {
params: Promise.resolve({ id: "token-1" }),
});
expect(pluralResponse.status).toBe(200);
expect(pluralResponse.headers.get("content-disposition")).toContain('filename="Main"');
expect(pluralResponse.headers.get("content-disposition")).not.toContain(".yaml");
expect(pluralResponse.headers.get("subscription-userinfo")).toBe(
"upload=64; download=128; total=1024; expire=1781635200"
);
expect(pluralResponse.headers.get("profile-update-interval")).toBe("24");
expect(await pluralResponse.text()).toBe("mixed-port: 7890\n");
expect(generateSubscriptionYaml).toHaveBeenCalledWith("token-1");
});
Expand Down
1 change: 1 addition & 0 deletions packages/server-core/src/subscription/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from "./fetch-profile-heuristics";
export * from "./manual-refresh-response";
export * from "./refresh-cache-result";
export * from "./refresh-node-snapshot";
export * from "./response-headers";
export * from "./saved-sources";
export * from "./source-import";
export * from "./ssrf-ip";
48 changes: 48 additions & 0 deletions packages/server-core/src/subscription/response-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";

import { buildSubscriptionResponseHeaders } from "./response-headers";

describe("subscription response headers", () => {
it("uses the subscription name without adding yaml to client-visible filenames", () => {
const headers = buildSubscriptionResponseHeaders("我的配置 2026/06/17.yaml", {}, { isAdmin: false });

expect(headers["content-disposition"]).not.toContain("filename=");
expect(headers["content-disposition"]).toContain("filename*=UTF-8''");
expect(decodeURIComponent(headers["content-disposition"].split("filename*=UTF-8''")[1])).toBe(
"我的配置 2026/06/17"
);
expect(headers["content-disposition"]).not.toContain(".yaml");
});

it("keeps a plain filename fallback for safe ASCII subscription names", () => {
const headers = buildSubscriptionResponseHeaders("Main", {}, { isAdmin: true });

expect(headers["content-disposition"]).toBe("attachment; filename=\"Main\"; filename*=UTF-8''Main");
});

it("serializes traffic and expiry metadata for proxy clients", () => {
const headers = buildSubscriptionResponseHeaders(
"Main",
{
upload: 1024,
download: 2048,
total: 4096,
expire: 1781635200,
planName: "Pro",
profileWebPageUrl: "https://example.com/account",
},
{
cacheControl: "no-store",
cacheExpirySeconds: 3600,
autoUpdateIntervalSeconds: 86400,
isAdmin: true,
}
);

expect(headers["cache-control"]).toBe("no-store");
expect(headers["subscription-userinfo"]).toBe("upload=1024; download=2048; total=4096; expire=1781635200");
expect(headers["profile-update-interval"]).toBe("24");
expect(headers["plan-name"]).toBe("Pro");
expect(headers["profile-web-page-url"]).toBe("https://example.com/account");
});
});
82 changes: 82 additions & 0 deletions packages/server-core/src/subscription/response-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
resolveClientProfileUpdateIntervalSeconds,
type SubscriptionResponseInfo,
} from "@subboost/core/subscription/subscription-response-info";

type BuildSubscriptionResponseHeadersOptions = {
cacheControl?: string;
cacheExpirySeconds?: number;
autoUpdateIntervalSeconds?: number | null;
isAdmin: boolean;
};

function normalizeHeaderFileNameBase(name: string): string {
return (
String(name || "config")
.trim()
.replace(/[\r\n"]/g, "")
.replace(/\.(?:ya?ml)$/i, "")
.slice(0, 80) || "config"
);
}

function normalizeAsciiFileNameBase(name: string): string {
return (
name
// 仅保留可打印 ASCII,避免部分客户端忽略 filename* 时出现乱码。
.replace(/[^\x20-\x7E]+/g, "")
// 去掉 Windows/macOS 常见非法文件名字符。
.replace(/[<>:"/\\|?*]+/g, "")
.trim()
.replace(/\s+/g, "_")
.slice(0, 60) || "config"
);
}

function serializeSubscriptionUserInfo(subscriptionInfo: SubscriptionResponseInfo): string | null {
const parts: string[] = [];
if (subscriptionInfo.upload !== undefined) parts.push(`upload=${subscriptionInfo.upload}`);
if (subscriptionInfo.download !== undefined) parts.push(`download=${subscriptionInfo.download}`);
if (subscriptionInfo.total !== undefined) parts.push(`total=${subscriptionInfo.total}`);
if (subscriptionInfo.expire !== undefined) parts.push(`expire=${subscriptionInfo.expire}`);
return parts.length > 0 ? parts.join("; ") : null;
}

function buildContentDisposition(name: string): string {
const safeNameBase = normalizeHeaderFileNameBase(name);
const asciiFileNameBase = normalizeAsciiFileNameBase(safeNameBase);
const encodedFileName = encodeURIComponent(safeNameBase);
if (asciiFileNameBase === safeNameBase) {
return `attachment; filename=\"${asciiFileNameBase}\"; filename*=UTF-8''${encodedFileName}`;
}
return `attachment; filename*=UTF-8''${encodedFileName}`;
}

export function buildSubscriptionResponseHeaders(
name: string,
subscriptionInfo: SubscriptionResponseInfo,
options: BuildSubscriptionResponseHeadersOptions
): Record<string, string> {
const headers: Record<string, string> = {
"content-type": "text/yaml;charset=utf-8",
// 订阅客户端可能把 filename 当作订阅显示名,响应头不要补 .yaml。
"content-disposition": buildContentDisposition(name),
"cache-control": options.cacheControl ?? "no-cache",
};

const recommendedIntervalSeconds = resolveClientProfileUpdateIntervalSeconds({
cacheExpirySeconds: options.cacheExpirySeconds,
autoUpdateIntervalSeconds: options.autoUpdateIntervalSeconds,
isAdmin: options.isAdmin,
});
if (typeof recommendedIntervalSeconds === "number" && Number.isFinite(recommendedIntervalSeconds)) {
headers["profile-update-interval"] = String(Math.max(1, Math.ceil(recommendedIntervalSeconds / 3600)));
}

const userInfoHeader = serializeSubscriptionUserInfo(subscriptionInfo);
if (userInfoHeader) headers["subscription-userinfo"] = userInfoHeader;
if (subscriptionInfo.profileWebPageUrl) headers["profile-web-page-url"] = subscriptionInfo.profileWebPageUrl;
if (subscriptionInfo.planName) headers["plan-name"] = subscriptionInfo.planName;

return headers;
}
Loading