diff --git a/local/app/api/auth/local-auth-routes.test.ts b/local/app/api/auth/local-auth-routes.test.ts index 908b4c07..3a172e65 100644 --- a/local/app/api/auth/local-auth-routes.test.ts +++ b/local/app/api/auth/local-auth-routes.test.ts @@ -3,6 +3,7 @@ import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit"; const mocks = vi.hoisted(() => ({ bcryptCompare: vi.fn(), + cleanupExpiredSessionRevocations: vi.fn(async () => 0), clearSessionCookieOptions: vi.fn(() => ({ maxAge: 0, path: "/" })), getCurrentAdmin: vi.fn(), isSetupRequired: vi.fn(), @@ -20,6 +21,8 @@ const mocks = vi.hoisted(() => ({ }, }, sessionCookieOptions: vi.fn(() => ({ httpOnly: true, path: "/" })), + revokeCurrentSession: vi.fn(async () => true), + SessionRevocationStoreUnavailableError: class SessionRevocationStoreUnavailableError extends Error {}, signSession: vi.fn(async () => "signed-session"), })); @@ -37,7 +40,10 @@ vi.mock("@local/lib/prisma", () => ({ })); vi.mock("@local/lib/session", () => ({ + cleanupExpiredSessionRevocations: mocks.cleanupExpiredSessionRevocations, clearSessionCookieOptions: mocks.clearSessionCookieOptions, + revokeCurrentSession: mocks.revokeCurrentSession, + SessionRevocationStoreUnavailableError: mocks.SessionRevocationStoreUnavailableError, SESSION_COOKIE: "subboost-local-session", sessionCookieOptions: mocks.sessionCookieOptions, signSession: mocks.signSession, @@ -114,15 +120,55 @@ describe("local auth and health routes", () => { }); }); - it("logs out by clearing the session cookie", async () => { + it("persists logout revocation before clearing the session cookie", async () => { const { POST } = await import("./logout/route"); const response = await POST(); expect(await readJson(response)).toEqual({ status: 200, body: { success: true } }); + expect(mocks.revokeCurrentSession).toHaveBeenCalledTimes(1); expect(response.headers.get("set-cookie")).toContain("subboost-local-session="); }); + it("does not clear the cookie or report success when revocation storage fails", async () => { + const { POST } = await import("./logout/route"); + mocks.revokeCurrentSession.mockRejectedValueOnce( + new mocks.SessionRevocationStoreUnavailableError("db down") + ); + + const response = await POST(); + + expect(await readJson(response)).toEqual({ + status: 503, + body: { error: "Session service unavailable.", code: "SESSION_STORE_UNAVAILABLE" }, + }); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + it("preserves successful logout when expired-session cleanup fails", async () => { + const { POST } = await import("./logout/route"); + const cause = new Error("cleanup unavailable"); + mocks.cleanupExpiredSessionRevocations.mockRejectedValueOnce(cause); + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const response = await POST(); + expect(await readJson(response)).toEqual({ status: 200, body: { success: true } }); + expect(response.headers.get("set-cookie")).toContain("subboost-local-session="); + expect(log).toHaveBeenCalledWith("Local session revocation cleanup failed:", cause); + } finally { + log.mockRestore(); + } + }); + + it("propagates unexpected revocation errors without clearing the cookie", async () => { + const { POST } = await import("./logout/route"); + const cause = new Error("unexpected failure"); + mocks.revokeCurrentSession.mockRejectedValueOnce(cause); + await expect(POST()).rejects.toBe(cause); + expect(mocks.clearSessionCookieOptions).not.toHaveBeenCalled(); + expect(mocks.cleanupExpiredSessionRevocations).not.toHaveBeenCalled(); + }); + it("returns the current admin snapshot and anonymous setup state", async () => { const { GET } = await import("./me/route"); mocks.isSetupRequired.mockResolvedValueOnce(false).mockResolvedValueOnce(true); diff --git a/local/app/api/auth/logout/route.ts b/local/app/api/auth/logout/route.ts index 6b872230..ae617cd8 100644 --- a/local/app/api/auth/logout/route.ts +++ b/local/app/api/auth/logout/route.ts @@ -1,8 +1,33 @@ import { NextResponse } from "next/server"; -import { clearSessionCookieOptions, SESSION_COOKIE } from "@local/lib/session"; +import { + cleanupExpiredSessionRevocations, + clearSessionCookieOptions, + revokeCurrentSession, + SessionRevocationStoreUnavailableError, + SESSION_COOKIE, +} from "@local/lib/session"; export async function POST() { + try { + await revokeCurrentSession(); + } catch (error) { + if (error instanceof SessionRevocationStoreUnavailableError) { + return NextResponse.json( + { error: "Session service unavailable.", code: "SESSION_STORE_UNAVAILABLE" }, + { status: 503 } + ); + } + throw error; + } + const response = NextResponse.json({ success: true }); response.cookies.set(SESSION_COOKIE, "", clearSessionCookieOptions()); + + try { + await cleanupExpiredSessionRevocations(); + } catch (error) { + console.error("Local session revocation cleanup failed:", error); + } + return response; } diff --git a/local/prisma/migrations/20260920090000_revoked_sessions/migration.sql b/local/prisma/migrations/20260920090000_revoked_sessions/migration.sql new file mode 100644 index 00000000..91167799 --- /dev/null +++ b/local/prisma/migrations/20260920090000_revoked_sessions/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE "RevokedSession" ( + "revocationKey" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RevokedSession_pkey" PRIMARY KEY ("revocationKey") +); + +CREATE INDEX "RevokedSession_expiresAt_idx" ON "RevokedSession"("expiresAt"); diff --git a/local/prisma/schema.prisma b/local/prisma/schema.prisma index 3457ffed..4006ecbb 100644 --- a/local/prisma/schema.prisma +++ b/local/prisma/schema.prisma @@ -86,3 +86,11 @@ model JobLeaseLock { @@index([expiresAt]) } + +model RevokedSession { + revocationKey String @id + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([expiresAt]) +} diff --git a/local/src/lib/auto-update-service.test.ts b/local/src/lib/auto-update-service.test.ts index 137bf632..a38f30a8 100644 --- a/local/src/lib/auto-update-service.test.ts +++ b/local/src/lib/auto-update-service.test.ts @@ -104,6 +104,7 @@ describe("local subscription auto update service", () => { }); mocks.prepareRefreshCacheResult.mockReturnValue({ ok: true, + refreshedConfig: { rules: [], sources: [{ url: "https://airport.example/sub" }] }, cacheEntry: { nodes: [{ name: "A" }], subscriptionInfo: { upload: 1 } }, nodeCount: 1, }); diff --git a/local/src/lib/auto-update-service.ts b/local/src/lib/auto-update-service.ts index ba5c1a37..762831b2 100644 --- a/local/src/lib/auto-update-service.ts +++ b/local/src/lib/auto-update-service.ts @@ -187,15 +187,13 @@ async function completeSuccess(params: { maxNodesPerSubscription: MAX_NODES_PER_SUBSCRIPTION, }); if (decision.kind !== "success") throw new Error(`Unexpected refresh completion decision: ${decision.kind}`); - const config = { ...params.prepared.config, sources: params.prepared.snapshot.savedSources }; - const persisted = await writeAutoUpdateState( params.subscription.id, params.subscription.updatedAt, decision.nextAutoUpdateState.state, { encryptedNodes: encryptJson(refreshResult.cacheEntry.nodes), - encryptedConfig: encryptJson(config), + encryptedConfig: encryptJson(refreshResult.refreshedConfig), encryptedSubscriptionInfo: encryptJson(refreshResult.cacheEntry.subscriptionInfo), lastUpdatedAt: cachedAt, cacheExpiresAt: buildSubscriptionCacheExpiry(cachedAt), diff --git a/local/src/lib/session.test.ts b/local/src/lib/session.test.ts index 67d7468b..cc90b23c 100644 --- a/local/src/lib/session.test.ts +++ b/local/src/lib/session.test.ts @@ -3,6 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ cookieValue: undefined as string | undefined, jwtVerify: vi.fn(), + prisma: { + revokedSession: { + deleteMany: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, signPayload: null as unknown, })); @@ -25,6 +33,14 @@ vi.mock("jose", () => ({ mocks.signPayload = { ...(this.payload as Record), sub: subject }; return this; } + setIssuer(issuer: string) { + mocks.signPayload = { ...(mocks.signPayload as Record), iss: issuer }; + return this; + } + setJti(jti: string) { + mocks.signPayload = { ...(mocks.signPayload as Record), jti }; + return this; + } setIssuedAt() { return this; } @@ -38,10 +54,15 @@ vi.mock("jose", () => ({ jwtVerify: mocks.jwtVerify, })); +vi.mock("./prisma", () => ({ prisma: mocks.prisma })); + import { clearSessionCookieOptions, + cleanupExpiredSessionRevocations, readSession, + revokeCurrentSession, sessionCookieOptions, + SessionRevocationStoreUnavailableError, signSession, } from "./session"; @@ -52,30 +73,129 @@ describe("local session helpers", () => { mocks.signPayload = null; process.env.JWT_SECRET = "test-secret"; process.env.APP_URL = "https://local.example"; - mocks.jwtVerify.mockResolvedValue({ payload: { sub: "admin-1", username: "ry" } }); + mocks.prisma.revokedSession.findUnique.mockResolvedValue(null); + mocks.prisma.revokedSession.findMany.mockResolvedValue([]); + mocks.prisma.revokedSession.deleteMany.mockResolvedValue({ count: 0 }); + mocks.prisma.revokedSession.upsert.mockResolvedValue({}); + mocks.jwtVerify.mockResolvedValue({ + payload: { exp: 4102444800, iss: "subboost-local", jti: "session-1", sub: "admin-1", username: "ry" }, + }); }); it("signs sessions with the admin id as JWT subject", async () => { await expect(signSession({ adminId: "admin-1", username: "ry" })).resolves.toBe("signed-session-token"); - expect(mocks.signPayload).toEqual({ sub: "admin-1", username: "ry" }); + expect(mocks.signPayload).toEqual({ + iss: "subboost-local", + jti: expect.any(String), + sub: "admin-1", + username: "ry", + }); }); it("reads valid sessions and rejects missing, malformed, or invalid tokens", async () => { await expect(readSession()).resolves.toBeNull(); - mocks.cookieValue = "session-token"; + mocks.cookieValue = "header.payload.signature"; await expect(readSession()).resolves.toEqual({ adminId: "admin-1", username: "ry" }); - mocks.jwtVerify.mockResolvedValueOnce({ payload: { sub: 123, username: "ry" } }); + mocks.prisma.revokedSession.findUnique.mockResolvedValueOnce({ revocationKey: "revoked" }); await expect(readSession()).resolves.toBeNull(); - mocks.jwtVerify.mockResolvedValueOnce({ payload: { sub: "admin-1", username: "" } }); + mocks.jwtVerify.mockResolvedValueOnce({ payload: { exp: 4102444800, sub: 123, username: "ry" } }); + await expect(readSession()).resolves.toBeNull(); + + mocks.jwtVerify.mockResolvedValueOnce({ payload: { exp: 4102444800, sub: "admin-1", username: "" } }); await expect(readSession()).resolves.toBeNull(); mocks.jwtVerify.mockRejectedValueOnce(new Error("bad token")); await expect(readSession()).resolves.toBeNull(); }); + it("fails closed when revocation state cannot be read", async () => { + mocks.cookieValue = "header.payload.signature"; + mocks.prisma.revokedSession.findUnique.mockRejectedValueOnce(new Error("db down")); + + await expect(readSession()).rejects.toBeInstanceOf(SessionRevocationStoreUnavailableError); + }); + + it.each([ + { exp: 4102444800, iss: "another-app", sub: "admin-1", username: "admin" }, + { exp: 4102444800, sub: "admin-1", username: 123 }, + ])("rejects invalid session claims without querying revocations", async (payload) => { + mocks.cookieValue = "header.payload.signature"; + mocks.jwtVerify.mockResolvedValueOnce({ payload }); + + await expect(readSession()).resolves.toBeNull(); + expect(mocks.prisma.revokedSession.findUnique).not.toHaveBeenCalled(); + }); + + it("does not persist revocations for missing or invalid sessions", async () => { + await expect(revokeCurrentSession()).resolves.toBe(false); + mocks.cookieValue = "invalid-token"; + mocks.jwtVerify.mockRejectedValueOnce(new Error("invalid signature")); + await expect(revokeCurrentSession()).resolves.toBe(false); + expect(mocks.prisma.revokedSession.upsert).not.toHaveBeenCalled(); + }); + + it("reports revocation write failures with their original cause", async () => { + mocks.cookieValue = "header.payload.signature"; + const cause = new Error("database unavailable"); + mocks.prisma.revokedSession.upsert.mockRejectedValueOnce(cause); + + await expect(revokeCurrentSession()).rejects.toMatchObject({ + name: "SessionRevocationStoreUnavailableError", + cause, + }); + }); + + it.each([undefined, {}])("uses bounded cleanup defaults without deleting an empty batch", async (options) => { + const before = Date.now(); + await expect(cleanupExpiredSessionRevocations(options)).resolves.toBe(0); + const query = mocks.prisma.revokedSession.findMany.mock.calls[0][0]; + expect(query.take).toBe(100); + expect(query.where.expiresAt.lte.getTime()).toBeGreaterThanOrEqual(before); + expect(query.where.expiresAt.lte.getTime()).toBeLessThanOrEqual(Date.now()); + expect(mocks.prisma.revokedSession.deleteMany).not.toHaveBeenCalled(); + }); + + it.each([[0, 1], [3.9, 3], [2000, 1000]])("bounds cleanup limit %s to %s", async (limit, expected) => { + const now = new Date("2026-09-20T00:00:00.000Z"); + await expect(cleanupExpiredSessionRevocations({ limit, now })).resolves.toBe(0); + expect(mocks.prisma.revokedSession.findMany).toHaveBeenCalledWith({ + where: { expiresAt: { lte: now } }, + select: { revocationKey: true }, + orderBy: { expiresAt: "asc" }, + take: expected, + }); + }); + + it("revokes the current session idempotently and cleans expired rows in a bounded batch", async () => { + mocks.cookieValue = "header.payload.signature"; + await expect(revokeCurrentSession()).resolves.toBe(true); + await expect(revokeCurrentSession()).resolves.toBe(true); + expect(mocks.prisma.revokedSession.upsert).toHaveBeenCalledTimes(2); + expect(mocks.prisma.revokedSession.upsert).toHaveBeenCalledWith({ + where: { revocationKey: expect.any(String) }, + create: { revocationKey: expect.any(String), expiresAt: expect.any(Date) }, + update: { expiresAt: expect.any(Date) }, + }); + + mocks.prisma.revokedSession.findMany.mockResolvedValueOnce([ + { revocationKey: "expired-1" }, + { revocationKey: "expired-2" }, + ]); + mocks.prisma.revokedSession.deleteMany.mockResolvedValueOnce({ count: 2 }); + const now = new Date("2026-09-20T00:00:00.000Z"); + + await expect(cleanupExpiredSessionRevocations({ limit: 2, now })).resolves.toBe(2); + expect(mocks.prisma.revokedSession.findMany).toHaveBeenCalledWith({ + where: { expiresAt: { lte: now } }, + select: { revocationKey: true }, + orderBy: { expiresAt: "asc" }, + take: 2, + }); + }); + it("builds secure session cookie options and clear options", () => { expect(sessionCookieOptions()).toEqual({ httpOnly: true, diff --git a/local/src/lib/session.ts b/local/src/lib/session.ts index 30143fff..65040be7 100644 --- a/local/src/lib/session.ts +++ b/local/src/lib/session.ts @@ -1,14 +1,34 @@ +import { randomUUID } from "node:crypto"; +import { + deriveSessionRevocationIdentity, + SESSION_CLOCK_TOLERANCE_SECONDS, +} from "@subboost/server-core/session-revocation"; import { cookies } from "next/headers"; import { jwtVerify, SignJWT } from "jose"; import { isHttpsAppUrl, requireEnv } from "./env"; +import { prisma } from "./prisma"; export const SESSION_COOKIE = "subboost_local_session"; +const SESSION_ISSUER = "subboost-local"; +const SESSION_REVOCATION_NAMESPACE = "subboost-local"; export type SessionPayload = { adminId: string; username: string; }; +type VerifiedSession = SessionPayload & { + expiresAt: Date; + revocationKey: string; +}; + +export class SessionRevocationStoreUnavailableError extends Error { + constructor(cause?: unknown) { + super("Session revocation state is unavailable.", { cause }); + this.name = "SessionRevocationStoreUnavailableError"; + } +} + function key(): Uint8Array { return new TextEncoder().encode(requireEnv("JWT_SECRET")); } @@ -17,24 +37,91 @@ export async function signSession(payload: SessionPayload): Promise { return await new SignJWT({ username: payload.username }) .setProtectedHeader({ alg: "HS256" }) .setSubject(payload.adminId) + .setIssuer(SESSION_ISSUER) + .setJti(randomUUID()) .setIssuedAt() .setExpirationTime("7d") .sign(key()); } -export async function readSession(): Promise { - const token = (await cookies()).get(SESSION_COOKIE)?.value; - if (!token) return null; +async function verifySignedSession(token: string): Promise { try { - const { payload } = await jwtVerify(token, key()); + const { payload } = await jwtVerify(token, key(), { + algorithms: ["HS256"], + clockTolerance: SESSION_CLOCK_TOLERANCE_SECONDS, + }); + if (payload.iss !== undefined && payload.iss !== SESSION_ISSUER) return null; const adminId = typeof payload.sub === "string" ? payload.sub : ""; const username = typeof payload.username === "string" ? payload.username : ""; - return adminId && username ? { adminId, username } : null; + if (!adminId || !username) return null; + const identity = deriveSessionRevocationIdentity({ + namespace: SESSION_REVOCATION_NAMESPACE, + token, + claims: payload, + }); + return { adminId, username, expiresAt: identity.expiresAt, revocationKey: identity.key }; } catch { return null; } } +async function currentSessionToken(): Promise { + return (await cookies()).get(SESSION_COOKIE)?.value ?? null; +} + +export async function readSession(): Promise { + const token = await currentSessionToken(); + if (!token) return null; + const session = await verifySignedSession(token); + if (!session) return null; + + try { + const revoked = await prisma.revokedSession.findUnique({ + where: { revocationKey: session.revocationKey }, + select: { revocationKey: true }, + }); + return revoked ? null : { adminId: session.adminId, username: session.username }; + } catch (error) { + throw new SessionRevocationStoreUnavailableError(error); + } +} + +export async function revokeCurrentSession(): Promise { + const token = await currentSessionToken(); + if (!token) return false; + const session = await verifySignedSession(token); + if (!session) return false; + + try { + await prisma.revokedSession.upsert({ + where: { revocationKey: session.revocationKey }, + create: { revocationKey: session.revocationKey, expiresAt: session.expiresAt }, + update: { expiresAt: session.expiresAt }, + }); + return true; + } catch (error) { + throw new SessionRevocationStoreUnavailableError(error); + } +} + +export async function cleanupExpiredSessionRevocations(options?: { + limit?: number; + now?: Date; +}): Promise { + const limit = Math.max(1, Math.min(1000, Math.floor(options?.limit ?? 100))); + const rows = await prisma.revokedSession.findMany({ + where: { expiresAt: { lte: options?.now ?? new Date() } }, + select: { revocationKey: true }, + orderBy: { expiresAt: "asc" }, + take: limit, + }); + if (rows.length === 0) return 0; + const result = await prisma.revokedSession.deleteMany({ + where: { revocationKey: { in: rows.map((row) => row.revocationKey) } }, + }); + return result.count; +} + export function sessionCookieOptions() { return { httpOnly: true, diff --git a/local/src/lib/subscription-service.test.ts b/local/src/lib/subscription-service.test.ts index e8b474b6..a45c31cb 100644 --- a/local/src/lib/subscription-service.test.ts +++ b/local/src/lib/subscription-service.test.ts @@ -153,7 +153,11 @@ describe("local subscription service", () => { beforeEach(() => { vi.clearAllMocks(); mocks.getAppUrl.mockReturnValue("http://127.0.0.1:3001"); - mocks.prepareRefreshCacheResult.mockReturnValue({ ok: true, nodeCount: 1 }); + mocks.prepareRefreshCacheResult.mockReturnValue({ + ok: true, + nodeCount: 1, + refreshedConfig: { sources: [{ id: "source-1", type: "url", content: "https://example.com/sub" }] }, + }); mocks.refreshNodeSnapshot.mockResolvedValue({ nodes: [node("Fresh")], savedSources: [{ id: "source-1", type: "url", content: "https://example.com/sub" }], diff --git a/local/src/lib/subscription-service.ts b/local/src/lib/subscription-service.ts index 49b474c5..ab567c61 100644 --- a/local/src/lib/subscription-service.ts +++ b/local/src/lib/subscription-service.ts @@ -358,7 +358,7 @@ async function persistRefreshSuccess(params: { where: { id: params.subscriptionId, updatedAt: params.expectedUpdatedAt }, data: { encryptedNodes: encryptJson(params.snapshot.nodes), - encryptedConfig: encryptJson({ ...params.config, sources: params.snapshot.savedSources }), + encryptedConfig: encryptJson(params.config), encryptedSubscriptionInfo: encryptJson(params.snapshot.subscriptionInfo), lastUpdatedAt: params.cachedAt, cacheExpiresAt: buildSubscriptionCacheExpiry(params.cachedAt), @@ -407,7 +407,7 @@ export async function refreshSubscription(ownerId: string, id: string) { subscriptionId: row.id, expectedUpdatedAt: row.updatedAt, snapshot, - config: secrets.config, + config: refreshResult.refreshedConfig, cachedAt, }); if (!persisted) { diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index 19ddd86d..53e0f91d 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -435,6 +435,42 @@ describe("generateClashConfig", () => { }); }); + it("keeps only enabled proxy groups as dialer relays and targets concrete nodes", () => { + const config = generateClashConfig({ + nodes: [ssNode({ name: "Relay" }), ssNode({ name: "Target", server: "target.example.com" })], + customProxyGroups: [ + { id: "custom", name: "🧩 筛选组 美国", emoji: "🧩", groupType: "select" }, + { id: "disabled", name: "🧩 已停用", emoji: "🧩", enabled: false, groupType: "select" }, + ], + proxyGroupNameOverrides: { auto: "自定义自动", select: "自定义选择" }, + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + type: "select", + relayNodes: [ + "🧩 筛选组 美国", + "🧩 已停用", + "⚡ 自定义自动", + "🚀 自定义选择", + "DIRECT", + "Relay", + "Missing", + ], + targetNodes: ["🧩 筛选组 美国", "⚡ 自定义自动", "Target", "Missing"], + }, + ], + userConfig: { dnsYaml: "", enabledGroups: ["auto", "final"] }, + }); + + expect(config["proxy-groups"]?.find((group) => group.name === "Chain")).toMatchObject({ + proxies: ["🧩 筛选组 美国", "⚡ 自定义自动", "DIRECT", "Relay"], + }); + expect(config.proxies?.find((proxy) => proxy.name === "Target")).toMatchObject({ + "dialer-proxy": "Chain", + }); + }); + it("uses default base config when base YAML is omitted and skips malformed ordered group names", () => { const config = generateClashConfig({ nodes: [ssNode()], diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index cfb476eb..e554f99f 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -35,6 +35,7 @@ import type { import type { DialerProxyGroup } from "@subboost/core/types/template-config"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "../mihomo/proxy-sanitizer"; +import { getValidDialerRelayGroupNames } from "../subscription/dialer-relay-group-names"; import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-targets"; import { resolveGroupListenerEntries, type GroupListenerTargetResolution } from "./group-listeners"; @@ -287,18 +288,17 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { const nodeNameSet = new Set(uniqueNodes.map((n) => n.name)); const activeCustomProxyGroups = customProxyGroups.filter((g) => g && g.enabled !== false); - const customGroupNameSet = new Set( - activeCustomProxyGroups.filter((g) => g && typeof g.name === "string" && g.name.trim()).map((g) => g.name.trim()) - ); - const moduleGroupNameSet = new Set( - PROXY_GROUP_MODULES.map((mod) => resolveProxyGroupModuleName(mod, proxyGroupNameOverrides?.[mod.id])) - ); + const validRelayGroupNames = getValidDialerRelayGroupNames({ + customProxyGroups, + enabledGroups: config.enabledGroups, + proxyGroupNameOverrides, + }); const enabledDialerProxyGroups = dialerProxyGroups.filter((g) => g && g.enabled !== false); const sanitizedDialerProxyGroups = enabledDialerProxyGroups.length > 0 ? sanitizeDialerProxyGroups( enabledDialerProxyGroups, nodeNameSet, - new Set([...moduleGroupNameSet, ...customGroupNameSet]) + validRelayGroupNames ) : []; @@ -310,8 +310,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { "DIRECT", ...nodeNameSet, ...proxyProviderNames, - ...moduleGroupNameSet, - ...customGroupNameSet, + ...validRelayGroupNames, ...sanitizedDialerProxyGroups.map((g) => g.name.trim()).filter(Boolean), ]); const outputNodes = allNodes.map((node) => { diff --git a/packages/core/src/subscription/dialer-relay-group-names.test.ts b/packages/core/src/subscription/dialer-relay-group-names.test.ts new file mode 100644 index 00000000..cb10a37e --- /dev/null +++ b/packages/core/src/subscription/dialer-relay-group-names.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { getValidDialerRelayGroupNames } from "./dialer-relay-group-names"; + +describe("dialer relay group names", () => { + it("resolves enabled custom and overridden builtin names", () => { + expect( + getValidDialerRelayGroupNames({ + customProxyGroups: [ + { name: " 🧩 筛选组 美国 " }, + { name: "🧩 已停用", enabled: false }, + ], + enabledGroups: ["auto"], + proxyGroupNameOverrides: { auto: "自定义自动", adult: "私密" }, + }) + ).toEqual(new Set(["🧩 筛选组 美国", "⚡ 自定义自动"])); + }); + + it("uses caller defaults when enabled groups are absent, empty, or malformed", () => { + const config = { proxyGroupNameOverrides: { auto: "自定义自动" } }; + expect(getValidDialerRelayGroupNames(config, { defaultEnabledGroups: ["auto"] })).toEqual( + new Set(["⚡ 自定义自动"]) + ); + expect(getValidDialerRelayGroupNames({ ...config, enabledGroups: [] }, { defaultEnabledGroups: ["auto"] })).toEqual( + new Set(["⚡ 自定义自动"]) + ); + expect( + getValidDialerRelayGroupNames( + { ...config, enabledGroups: "auto" }, + { defaultEnabledGroups: ["auto"] } + ) + ).toEqual(new Set(["⚡ 自定义自动"])); + }); + + it("falls back to no builtin groups when neither config nor defaults enable them", () => { + expect(getValidDialerRelayGroupNames({})).toEqual(new Set()); + }); +}); diff --git a/packages/core/src/subscription/dialer-relay-group-names.ts b/packages/core/src/subscription/dialer-relay-group-names.ts new file mode 100644 index 00000000..85ae88df --- /dev/null +++ b/packages/core/src/subscription/dialer-relay-group-names.ts @@ -0,0 +1,45 @@ +import { PROXY_GROUP_MODULES } from "../generator/proxy-group-modules"; +import { resolveProxyGroupModuleName } from "../proxy-group-name"; + +type DialerRelayGroupNameConfig = { + customProxyGroups?: unknown; + enabledGroups?: unknown; + proxyGroupNameOverrides?: unknown; +}; + +type DialerRelayGroupNameOptions = { + defaultEnabledGroups?: readonly string[]; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function getValidDialerRelayGroupNames( + config: DialerRelayGroupNameConfig, + options: DialerRelayGroupNameOptions = {} +): Set { + const customGroupNames = Array.isArray(config.customProxyGroups) + ? config.customProxyGroups + .filter((group) => isRecord(group) && group.enabled !== false) + .map((group) => (typeof group.name === "string" ? group.name.trim() : "")) + .filter(Boolean) + : []; + const overrides = isRecord(config.proxyGroupNameOverrides) ? config.proxyGroupNameOverrides : {}; + const configuredEnabledGroups = Array.isArray(config.enabledGroups) + ? config.enabledGroups + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + : []; + const enabledGroups = configuredEnabledGroups.length > 0 + ? configuredEnabledGroups + : [...(options.defaultEnabledGroups ?? [])]; + const enabledGroupIds = new Set(enabledGroups); + const builtinGroupNames = PROXY_GROUP_MODULES.filter((module) => enabledGroupIds.has(module.id)).map( + (module) => { + const override = overrides[module.id]; + return resolveProxyGroupModuleName(module, typeof override === "string" ? override : undefined); + } + ); + return new Set([...customGroupNames, ...builtinGroupNames]); +} diff --git a/packages/core/src/subscription/node-name-references.test.ts b/packages/core/src/subscription/node-name-references.test.ts new file mode 100644 index 00000000..522931d1 --- /dev/null +++ b/packages/core/src/subscription/node-name-references.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedNode } from "../types/node"; +import { + composeNodeNameRenameMaps, + reconcileNodeNameReferences, +} from "./node-name-references"; + +function node(name: string): ParsedNode { + return { name, type: "ss", server: "example.com", port: 443, cipher: "aes-128-gcm", password: "x" }; +} + +describe("node name references", () => { + it("composes sequential renames transitively", () => { + expect( + Array.from( + composeNodeNameRenameMaps( + new Map([["Old", "Middle"]]), + new Map([["Middle", "New"]]) + ) + ) + ).toEqual([ + ["Old", "New"], + ["Middle", "New"], + ]); + }); + + it("reconciles listener, dialer, and advanced node references", () => { + const config = { + untouched: true, + listenerPorts: { Old: 12000, Missing: 12001 }, + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + relayNodes: ["DIRECT", "Old", "Old", "Missing"], + targetNodes: ["Old", "Missing"], + }, + ], + proxyGroupAdvanced: { + auto: { + extraMembers: [{ kind: "node", name: "Old" }, { kind: "direct" }], + excludedMembers: [{ kind: "node", name: "Missing" }], + memberOrder: [{ kind: "node", name: "Old" }, { kind: "node", name: "Old" }], + }, + }, + }; + + expect( + reconcileNodeNameReferences(config, { + nodes: [node("New")], + renameMap: new Map([["Old", "New"]]), + }) + ).toEqual({ + untouched: true, + listenerPorts: { New: 12000 }, + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + relayNodes: ["DIRECT", "New"], + targetNodes: ["New"], + }, + ], + proxyGroupAdvanced: { + auto: { + extraMembers: [{ kind: "node", name: "New" }, { kind: "direct" }], + excludedMembers: [], + memberOrder: [{ kind: "node", name: "New" }], + }, + }, + }); + }); + + it("keeps references for nodes hidden only by the name filter", () => { + const config = { + listenerPorts: { Hidden: 12000 }, + dialerProxyGroups: [{ relayNodes: ["Hidden"], targetNodes: ["Hidden"] }], + }; + expect(reconcileNodeNameReferences(config, { nodes: [node("Hidden")] })).toEqual(config); + }); + + it("preserves valid proxy groups as relays while keeping targets node-only", () => { + const migratedCustomGroupName = "🧩 筛选组 美国"; + const config = { + customProxyGroups: [ + { id: "legacy-us", name: migratedCustomGroupName, enabled: true }, + { id: "default-enabled", name: " 🧩 默认启用 " }, + { id: "disabled", name: "🧩 已停用", enabled: false }, + ], + proxyGroupNameOverrides: { auto: "自定义自动", adult: "私密" }, + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + relayNodes: [ + migratedCustomGroupName, + migratedCustomGroupName, + "⚡ 自定义自动", + "🧩 默认启用", + "DIRECT", + "Old Node", + "🧩 已停用", + "🧩 不存在", + "🔞 私密", + ], + targetNodes: [migratedCustomGroupName, "⚡ 自定义自动", "DIRECT", "Old Node", "Missing"], + }, + ], + }; + + expect( + reconcileNodeNameReferences(config, { + nodes: [node("New Node")], + renameMap: new Map([ + ["Old Node", "New Node"], + [migratedCustomGroupName, "New Node"], + ]), + }).dialerProxyGroups + ).toEqual([ + { + id: "chain", + name: "Chain", + relayNodes: [migratedCustomGroupName, "⚡ 自定义自动", "🧩 默认启用", "DIRECT", "New Node"], + targetNodes: ["New Node"], + }, + ]); + }); + + it("uses the active template defaults only when enabled groups are omitted", () => { + const dialerProxyGroups = [ + { + relayNodes: ["⚡ 自动选择", "🤖 AI 服务"], + targetNodes: [], + }, + ]; + + expect( + reconcileNodeNameReferences({ template: "minimal", dialerProxyGroups }, { nodes: [] }) + .dialerProxyGroups[0].relayNodes + ).toEqual(["⚡ 自动选择"]); + expect( + reconcileNodeNameReferences({ template: "full", dialerProxyGroups }, { nodes: [] }) + .dialerProxyGroups[0].relayNodes + ).toEqual(["⚡ 自动选择", "🤖 AI 服务"]); + }); + + it("normalizes object rename maps and ignores blank or self mappings", () => { + expect( + Array.from( + composeNodeNameRenameMaps( + { " Old ": " Middle ", " ": "Ignored", Same: "Same" }, + { Middle: "New", New: "New", Empty: " " } + ) + ) + ).toEqual([ + ["Old", "New"], + ["Middle", "New"], + ]); + expect(Array.from(composeNodeNameRenameMaps())).toEqual([]); + expect(Array.from(composeNodeNameRenameMaps({ A: "B" }, { B: "A" }))).toEqual([["B", "A"]]); + expect( + Array.from( + composeNodeNameRenameMaps( + { Old: "Middle", Middle: "Final", Shared: "Existing" }, + { Shared: "Replacement" } + ) + ) + ).toEqual([ + ["Old", "Final"], + ["Middle", "Final"], + ["Shared", "Existing"], + ]); + }); + + it("preserves unknown shapes while pruning invalid listener values", () => { + const unknownGroup = null; + expect( + reconcileNodeNameReferences( + { + listenerPorts: { Valid: 1, Float: 1.5, Low: 0, High: 65536, Text: "12000" }, + dialerProxyGroups: [ + unknownGroup, + { + relayNodes: ["", 7, "DIRECT", "DIRECT", "Valid"], + targetNodes: "legacy", + }, + ], + proxyGroupAdvanced: { + invalid: null, + valid: { + extraMembers: "legacy", + excludedMembers: [null, { kind: "node" }, { kind: "module", id: "auto" }], + memberOrder: [{ kind: "node", name: "Valid" }], + }, + }, + }, + { nodes: [node("Valid"), node(" ")] } + ) + ).toEqual({ + listenerPorts: { Valid: 1 }, + dialerProxyGroups: [ + unknownGroup, + { + relayNodes: [7, "DIRECT", "Valid"], + targetNodes: "legacy", + }, + ], + proxyGroupAdvanced: { + invalid: null, + valid: { + extraMembers: "legacy", + excludedMembers: [null, { kind: "node" }, { kind: "module", id: "auto" }], + memberOrder: [{ kind: "node", name: "Valid" }], + }, + }, + }); + }); + + it("leaves absent or non-record relationship sections untouched", () => { + expect(reconcileNodeNameReferences({ unrelated: true }, { nodes: [] })).toEqual({ unrelated: true }); + expect( + reconcileNodeNameReferences( + { listenerPorts: null, dialerProxyGroups: "legacy", proxyGroupAdvanced: [] }, + { nodes: [] } + ) + ).toEqual({ listenerPorts: null, dialerProxyGroups: "legacy", proxyGroupAdvanced: [] }); + }); +}); diff --git a/packages/core/src/subscription/node-name-references.ts b/packages/core/src/subscription/node-name-references.ts new file mode 100644 index 00000000..fbc8d8d7 --- /dev/null +++ b/packages/core/src/subscription/node-name-references.ts @@ -0,0 +1,196 @@ +import { buildDefaultUserConfig } from "../config/defaults"; +import type { ParsedNode } from "../types/node"; +import { getValidDialerRelayGroupNames } from "./dialer-relay-group-names"; + +export type NodeNameRenameMap = ReadonlyMap | Readonly>; + +type ReconcileNodeNameReferencesOptions = { + nodes: ParsedNode[]; + renameMap?: NodeNameRenameMap; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toRenameMap(value?: NodeNameRenameMap): Map { + const entries = value instanceof Map ? value.entries() : Object.entries(value ?? {}); + const out = new Map(); + for (const [rawFrom, rawTo] of entries) { + const from = rawFrom.trim(); + const to = rawTo.trim(); + if (!from || !to || from === to) continue; + out.set(from, to); + } + return out; +} + +function resolveRenamedNodeName(name: string, renameMap: ReadonlyMap): string { + let current = name; + const visited = new Set(); + while (!visited.has(current)) { + visited.add(current); + const next = renameMap.get(current); + if (!next) break; + current = next; + } + return current; +} + +export function composeNodeNameRenameMaps( + existing?: NodeNameRenameMap, + next?: NodeNameRenameMap +): Map { + const existingMap = toRenameMap(existing); + const nextMap = toRenameMap(next); + const out = new Map(); + + for (const [from, to] of existingMap) { + const resolved = resolveRenamedNodeName(resolveRenamedNodeName(to, existingMap), nextMap); + if (from !== resolved) out.set(from, resolved); + } + for (const [from, to] of nextMap) { + if (out.has(from)) continue; + const resolved = resolveRenamedNodeName(to, nextMap); + out.set(from, resolved); + } + + return out; +} + +function remapNameList( + value: unknown, + renameMap: ReadonlyMap, + availableNames: ReadonlySet, + options: { keepDirect?: boolean; preservedNames?: ReadonlySet } = {} +): unknown { + if (!Array.isArray(value)) return value; + const out: unknown[] = []; + const seenNames = new Set(); + for (const item of value) { + if (typeof item !== "string") { + out.push(item); + continue; + } + const name = item.trim(); + if (!name) continue; + if (options.keepDirect && name === "DIRECT") { + if (!seenNames.has(name)) out.push(name); + seenNames.add(name); + continue; + } + if (options.preservedNames?.has(name)) { + if (!seenNames.has(name)) out.push(name); + seenNames.add(name); + continue; + } + const nextName = resolveRenamedNodeName(name, renameMap); + if (!availableNames.has(nextName) || seenNames.has(nextName)) continue; + seenNames.add(nextName); + out.push(nextName); + } + return out; +} + +function remapAdvancedMemberList( + value: unknown, + renameMap: ReadonlyMap, + availableNames: ReadonlySet +): unknown { + if (!Array.isArray(value)) return value; + const out: unknown[] = []; + const seenNodeNames = new Set(); + for (const item of value) { + if (!isRecord(item) || item.kind !== "node" || typeof item.name !== "string") { + out.push(item); + continue; + } + const nextName = resolveRenamedNodeName(item.name.trim(), renameMap); + if (!nextName || !availableNames.has(nextName) || seenNodeNames.has(nextName)) continue; + seenNodeNames.add(nextName); + out.push(nextName === item.name ? item : { ...item, name: nextName }); + } + return out; +} + +function remapProxyGroupAdvanced( + value: unknown, + renameMap: ReadonlyMap, + availableNames: ReadonlySet +): unknown { + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([groupId, rawAdvanced]) => { + if (!isRecord(rawAdvanced)) return [groupId, rawAdvanced]; + return [ + groupId, + { + ...rawAdvanced, + ...(Object.hasOwn(rawAdvanced, "extraMembers") + ? { extraMembers: remapAdvancedMemberList(rawAdvanced.extraMembers, renameMap, availableNames) } + : {}), + ...(Object.hasOwn(rawAdvanced, "excludedMembers") + ? { excludedMembers: remapAdvancedMemberList(rawAdvanced.excludedMembers, renameMap, availableNames) } + : {}), + ...(Object.hasOwn(rawAdvanced, "memberOrder") + ? { memberOrder: remapAdvancedMemberList(rawAdvanced.memberOrder, renameMap, availableNames) } + : {}), + }, + ]; + }) + ); +} + +export function reconcileNodeNameReferences( + config: T, + options: ReconcileNodeNameReferencesOptions +): T { + const rawConfig = config as Record; + const renameMap = toRenameMap(options.renameMap); + const availableNames = new Set(options.nodes.map((node) => node.name.trim()).filter(Boolean)); + const template = + rawConfig.template === "minimal" || rawConfig.template === "standard" || rawConfig.template === "full" + ? rawConfig.template + : "standard"; + const validRelayGroupNames = getValidDialerRelayGroupNames(rawConfig, { + defaultEnabledGroups: buildDefaultUserConfig(template).enabledGroups, + }); + + const listenerPorts = isRecord(rawConfig.listenerPorts) + ? Object.fromEntries( + Object.entries(rawConfig.listenerPorts) + .map(([name, port]) => [resolveRenamedNodeName(name, renameMap), port] as const) + .filter( + ([name, port]) => + availableNames.has(name) && + typeof port === "number" && + Number.isInteger(port) && + port >= 1 && + port <= 65535 + ) + ) + : rawConfig.listenerPorts; + + const dialerProxyGroups = Array.isArray(rawConfig.dialerProxyGroups) + ? rawConfig.dialerProxyGroups.map((rawGroup) => { + if (!isRecord(rawGroup)) return rawGroup; + return { + ...rawGroup, + relayNodes: remapNameList(rawGroup.relayNodes, renameMap, availableNames, { + keepDirect: true, + preservedNames: validRelayGroupNames, + }), + targetNodes: remapNameList(rawGroup.targetNodes, renameMap, availableNames), + }; + }) + : rawConfig.dialerProxyGroups; + + return { + ...rawConfig, + ...(Object.hasOwn(rawConfig, "listenerPorts") ? { listenerPorts } : {}), + ...(Object.hasOwn(rawConfig, "dialerProxyGroups") ? { dialerProxyGroups } : {}), + ...(Object.hasOwn(rawConfig, "proxyGroupAdvanced") + ? { proxyGroupAdvanced: remapProxyGroupAdvanced(rawConfig.proxyGroupAdvanced, renameMap, availableNames) } + : {}), + } as T; +} diff --git a/packages/server-core/package.json b/packages/server-core/package.json index cd3f4640..0b3e0095 100644 --- a/packages/server-core/package.json +++ b/packages/server-core/package.json @@ -11,6 +11,7 @@ "./crypto": "./src/crypto/index.ts", "./crypto/*": "./src/crypto/*.ts", "./http": "./src/http.ts", + "./session-revocation": "./src/session-revocation.ts", "./rules": "./src/rules/index.ts", "./rules/*": "./src/rules/*.ts", "./templates": "./src/templates/index.ts", diff --git a/packages/server-core/src/index.ts b/packages/server-core/src/index.ts index 907c4c1c..e570a1d0 100644 --- a/packages/server-core/src/index.ts +++ b/packages/server-core/src/index.ts @@ -2,6 +2,7 @@ export * from "./app-version"; export * from "./cron-auth"; export * from "./crypto"; export * from "./http"; +export * from "./session-revocation"; export * from "./rules"; export * from "./subscription"; export * from "./templates"; diff --git a/packages/server-core/src/session-revocation.test.ts b/packages/server-core/src/session-revocation.test.ts new file mode 100644 index 00000000..2b2ed91a --- /dev/null +++ b/packages/server-core/src/session-revocation.test.ts @@ -0,0 +1,98 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + deriveSessionRevocationIdentity, + SESSION_CLOCK_TOLERANCE_SECONDS, +} from "./session-revocation"; + +function base64UrlJson(value: unknown) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function tokenFor(payload: Record, signature?: string) { + const signingInput = `${base64UrlJson({ alg: "HS256" })}.${base64UrlJson(payload)}`; + return `${signingInput}.${signature ?? createHmac("sha256", "secret").update(signingInput).digest("base64url")}`; +} + +describe("session revocation identity", () => { + it("uses the verified jti so separate sessions have separate keys", () => { + const first = deriveSessionRevocationIdentity({ + namespace: "service", + token: tokenFor({ exp: 100, jti: "session-a" }), + claims: { exp: 100, jti: "session-a" }, + }); + const second = deriveSessionRevocationIdentity({ + namespace: "service", + token: tokenFor({ exp: 100, jti: "session-b" }), + claims: { exp: 100, jti: "session-b" }, + }); + + expect(first.kind).toBe("jti"); + expect(first.key).not.toBe(second.key); + }); + + it("uses verified header.payload for legacy tokens, independent of signature spelling", () => { + const payload = { exp: 100, sub: "user-1" }; + const original = tokenFor(payload, "signature-one"); + const equivalent = tokenFor(payload, "signature-two"); + + const first = deriveSessionRevocationIdentity({ namespace: "service", token: original, claims: payload }); + const second = deriveSessionRevocationIdentity({ namespace: "service", token: equivalent, claims: payload }); + + expect(first.kind).toBe("legacy"); + expect(first.key).toBe(second.key); + }); + + it("separates namespaces and retains records through the verification tolerance", () => { + const token = tokenFor({ exp: 100, jti: "same-session" }); + const service = deriveSessionRevocationIdentity({ + namespace: "service", + token, + claims: { exp: 100, jti: "same-session" }, + }); + const local = deriveSessionRevocationIdentity({ + namespace: "local", + token, + claims: { exp: 100, jti: "same-session" }, + }); + + expect(service.key).not.toBe(local.key); + expect(service.expiresAt).toEqual(new Date((100 + SESSION_CLOCK_TOLERANCE_SECONDS) * 1000)); + }); + + it("rejects malformed legacy tokens and sessions without a bounded expiration", () => { + expect(() => + deriveSessionRevocationIdentity({ namespace: " ", token: "header.payload.signature", claims: { exp: 100 } }) + ).toThrow("namespace is required"); + expect(() => + deriveSessionRevocationIdentity({ namespace: "service", token: "broken", claims: { exp: 100 } }) + ).toThrow("compact JWS"); + expect(() => + deriveSessionRevocationIdentity({ + namespace: "service", + token: tokenFor({ jti: "session" }), + claims: { jti: "session" }, + }) + ).toThrow("expiration"); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])("rejects invalid expiration %s", (exp) => { + expect(() => deriveSessionRevocationIdentity({ + namespace: "local", + token: "header.payload.signature", + claims: { exp, jti: "session" }, + })).toThrow("expiration"); + }); + + it("uses legacy identity for blank jti and respects explicit clock tolerance", () => { + const token = tokenFor({ exp: 100 }); + const identity = deriveSessionRevocationIdentity({ + namespace: "local", token, claims: { exp: 100, jti: " " }, clockToleranceSeconds: 0, + }); + expect(identity.kind).toBe("legacy"); + expect(identity.expiresAt).toEqual(new Date(100000)); + expect(() => deriveSessionRevocationIdentity({ + namespace: "local", token: "header..signature", claims: { exp: 100 }, + })).toThrow("compact JWS"); + }); +}); diff --git a/packages/server-core/src/session-revocation.ts b/packages/server-core/src/session-revocation.ts new file mode 100644 index 00000000..8890179a --- /dev/null +++ b/packages/server-core/src/session-revocation.ts @@ -0,0 +1,55 @@ +import { createHash } from "node:crypto"; + +export const SESSION_CLOCK_TOLERANCE_SECONDS = 5; + +export type VerifiedSessionClaims = { + exp?: unknown; + jti?: unknown; +}; + +export type SessionRevocationIdentity = { + expiresAt: Date; + key: string; + kind: "jti" | "legacy"; +}; + +function compactHeaderAndPayload(token: string): string { + const parts = token.split("."); + if (parts.length !== 3 || parts.some((part) => part.length === 0)) { + throw new Error("Session token must be a compact JWS."); + } + return `${parts[0]}.${parts[1]}`; +} + +function acceptedUntil(exp: unknown, clockToleranceSeconds: number): Date { + if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= 0) { + throw new Error("Session token is missing a valid expiration time."); + } + return new Date((exp + clockToleranceSeconds) * 1000); +} + +export function deriveSessionRevocationIdentity(options: { + namespace: string; + token: string; + claims: VerifiedSessionClaims; + clockToleranceSeconds?: number; +}): SessionRevocationIdentity { + const namespace = options.namespace.trim(); + if (!namespace) throw new Error("Session revocation namespace is required."); + + const jti = typeof options.claims.jti === "string" ? options.claims.jti.trim() : ""; + const kind = jti ? "jti" : "legacy"; + const stableIdentity = jti || compactHeaderAndPayload(options.token); + const key = createHash("sha256") + .update(`subboost-session-revocation:v1:${namespace}:${kind}:${stableIdentity}`, "utf8") + .digest("hex"); + + return { + key, + kind, + expiresAt: acceptedUntil( + options.claims.exp, + options.clockToleranceSeconds ?? SESSION_CLOCK_TOLERANCE_SECONDS + ), + }; +} diff --git a/packages/server-core/src/subscription/automatic-refresh-completion.test.ts b/packages/server-core/src/subscription/automatic-refresh-completion.test.ts index 1515997e..2f387040 100644 --- a/packages/server-core/src/subscription/automatic-refresh-completion.test.ts +++ b/packages/server-core/src/subscription/automatic-refresh-completion.test.ts @@ -170,6 +170,7 @@ describe("automatic refresh completion helpers", () => { currentAutoUpdateState, prepared: makePrepared({ ok: true, + refreshedConfig: {}, cacheEntry: { nodes: [], subscriptionInfo: {}, generatedYaml: "yaml" }, generatedYaml: "yaml", nodeCount: 3, @@ -219,6 +220,7 @@ describe("automatic refresh completion helpers", () => { currentAutoUpdateState, prepared: makePrepared({ ok: true, + refreshedConfig: {}, cacheEntry: { nodes: [], subscriptionInfo: {}, generatedYaml: "yaml" }, generatedYaml: "yaml", nodeCount: undefined as never, diff --git a/packages/server-core/src/subscription/manual-refresh-response.test.ts b/packages/server-core/src/subscription/manual-refresh-response.test.ts index 723f2e52..6cd1c0ff 100644 --- a/packages/server-core/src/subscription/manual-refresh-response.test.ts +++ b/packages/server-core/src/subscription/manual-refresh-response.test.ts @@ -79,6 +79,7 @@ describe("manual refresh response helpers", () => { it("builds the manual refresh success response body from snapshot and cache timestamp", () => { const refreshResult: Extract = { ok: true, + refreshedConfig: {}, cacheEntry: { nodes: [], generatedYaml: "yaml", subscriptionInfo: {} }, generatedYaml: "yaml", nodeCount: 3, diff --git a/packages/server-core/src/subscription/refresh-cache-result.test.ts b/packages/server-core/src/subscription/refresh-cache-result.test.ts index cd3000df..4025a796 100644 --- a/packages/server-core/src/subscription/refresh-cache-result.test.ts +++ b/packages/server-core/src/subscription/refresh-cache-result.test.ts @@ -202,6 +202,102 @@ describe("prepareRefreshCacheResult", () => { }); }); + it("generates and persists from reconciled node relationships", () => { + const renamedNode = { ...node, name: "New Node" }; + const result = prepareRefreshCacheResult({ + config: { + listenerPorts: { "Old Node": 12000 }, + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + type: "select", + relayNodes: ["DIRECT", "Old Node"], + targetNodes: ["Old Node"], + }, + ], + proxyGroupAdvanced: { + auto: { memberOrder: [{ kind: "node", name: "Old Node" }] }, + }, + }, + snapshot: snapshot({ + nodes: [renamedNode], + savedSources: [{ id: "source", type: "url", content: "https://example.com/sub" }], + renameMap: new Map([["Old Node", "New Node"]]), + }), + maxNodesPerSubscription: 10, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.refreshedConfig).toMatchObject({ + listenerPorts: { "New Node": 12000 }, + dialerProxyGroups: [ + expect.objectContaining({ relayNodes: ["DIRECT", "New Node"], targetNodes: ["New Node"] }), + ], + proxyGroupAdvanced: { + auto: { memberOrder: [{ kind: "node", name: "New Node" }] }, + }, + sources: [{ id: "source", type: "url", content: "https://example.com/sub" }], + }); + expect(result.generatedYaml).toContain('port: 12000, proxy: "New Node"'); + expect(result.generatedYaml).toContain("dialer-proxy: Chain"); + }); + + it("preserves a migrated custom proxy group relay through refresh and YAML generation", () => { + const migratedCustomGroupName = "🧩 筛选组 美国"; + const result = prepareRefreshCacheResult({ + config: { + enabledGroups: ["select", "auto", "final"], + customProxyGroups: [ + { + id: "legacy-us", + name: migratedCustomGroupName, + emoji: "🧩", + enabled: true, + groupType: "select", + }, + ], + dialerProxyGroups: [ + { + id: "group-relay", + name: "Group Relay", + type: "select", + relayNodes: [migratedCustomGroupName], + targetNodes: ["node-a"], + }, + ], + }, + snapshot: snapshot(), + maxNodesPerSubscription: 10, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.refreshedConfig).toMatchObject({ + dialerProxyGroups: [ + expect.objectContaining({ relayNodes: [migratedCustomGroupName], targetNodes: ["node-a"] }), + ], + }); + expect(result.generatedYaml).toContain('dialer-proxy: "Group Relay"'); + expect(result.generatedYaml).toContain(migratedCustomGroupName); + }); + + it.each([[], "auto"])( + "keeps legacy template fallback for empty or malformed enabledGroups=%j", + (enabledGroups) => { + const result = prepareRefreshCacheResult({ + config: { enabledGroups }, + snapshot: snapshot(), + maxNodesPerSubscription: 10, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.generatedYaml).toContain("⚡ 自动选择"); + } + ); + it("rejects invalid persisted filters before publishing refresh output", () => { expect(() => prepareRefreshCacheResult({ diff --git a/packages/server-core/src/subscription/refresh-cache-result.ts b/packages/server-core/src/subscription/refresh-cache-result.ts index 433b40d6..466ee59d 100644 --- a/packages/server-core/src/subscription/refresh-cache-result.ts +++ b/packages/server-core/src/subscription/refresh-cache-result.ts @@ -5,6 +5,7 @@ import { } from "@subboost/core/subscription/config-utils"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; import { resolveNodeNameFilter } from "@subboost/core/subscription/node-name-filter"; +import { reconcileNodeNameReferences } from "@subboost/core/subscription/node-name-references"; import type { ParsedNode } from "@subboost/core/types/node"; import type { SubscriptionResponseInfo } from "@subboost/core/subscription/subscription-response-info"; import type { RefreshNodeSnapshotResult } from "./refresh-node-snapshot"; @@ -24,6 +25,7 @@ export type PreparedRefreshCacheResult = | { ok: true; cacheEntry: RefreshCacheEntry; + refreshedConfig: Record; generatedYaml: string; nodeCount: number; proxyProviders?: Record; @@ -86,8 +88,19 @@ export function prepareRefreshCacheResult(params: { }; } + const refreshedConfig = reconcileNodeNameReferences( + { + ...params.config, + sources: params.snapshot.savedSources, + }, + { + nodes: params.snapshot.nodes, + renameMap: params.snapshot.renameMap, + } + ); + const generatedYaml = generateClashYaml( - buildGenerateOptionsFromConfig(params.config, { + buildGenerateOptionsFromConfig(refreshedConfig, { nodes: params.snapshot.nodes, proxyProviders, }) @@ -96,6 +109,7 @@ export function prepareRefreshCacheResult(params: { return { ok: true, ...common, + refreshedConfig, generatedYaml, cacheEntry: { nodes: params.snapshot.nodes, diff --git a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts index dd71235a..10a51e69 100644 --- a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts +++ b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts @@ -51,6 +51,31 @@ describe("refreshNodeSnapshot", () => { expect(result.usedUrlFetch).toBe(true); }); + it("returns the smart-match rename needed by relationship reconciliation", async () => { + const result = await refreshNodeSnapshot({ + config: { + sources: [{ id: "source", type: "url", content: "https://example.com/sub" }], + }, + urls: [], + storedNodes: [ + { + ...node, + name: "Old Node", + _originName: "Old Node", + _sourceIds: ["source"], + } as ParsedNode, + ], + fetchUrlNodes: vi.fn(async () => ({ + ok: true, + nodes: [{ ...node, name: "New Node" }], + headers: {}, + })), + }); + + expect(result.nodes).toEqual([expect.objectContaining({ name: "New Node", _originName: "New Node" })]); + expect(Array.from(result.renameMap ?? [])).toEqual([["Old Node", "New Node"]]); + }); + it("detaches proxy-provider source nodes and still collects supplemental userinfo", async () => { const fetchUrlNodes = vi.fn(); const fetchUrlUserInfo = vi.fn(async () => ({ diff --git a/packages/server-core/src/subscription/refresh-node-snapshot.ts b/packages/server-core/src/subscription/refresh-node-snapshot.ts index ebb9e772..4e5fbf52 100644 --- a/packages/server-core/src/subscription/refresh-node-snapshot.ts +++ b/packages/server-core/src/subscription/refresh-node-snapshot.ts @@ -24,6 +24,7 @@ import { } from "@subboost/core/subscription/subscription-response-info"; import type { SubscriptionImportErrorCategory } from "@subboost/core/subscription/import-error"; import type { ParsedNode } from "@subboost/core/types/node"; +import { composeNodeNameRenameMaps } from "@subboost/core/subscription/node-name-references"; import { normalizeSavedSourcesForPersistence, type SavedSource, type SavedSourceType } from "./saved-sources"; type UrlNodeFetchResult = { @@ -62,6 +63,7 @@ export type RefreshNodeSnapshotOptions = { export type RefreshNodeSnapshotResult = { nodes: ParsedNode[]; + renameMap?: ReadonlyMap; subscriptionInfo: SubscriptionResponseInfo; savedSources: SavedSource[]; attemptedUrlFetch: boolean; @@ -141,6 +143,7 @@ export async function refreshNodeSnapshot( let detachedSourceCount = 0; let failedSourceCount = 0; const failedSources: RefreshNodeSnapshotFailedSource[] = []; + let renameMap = new Map(); const recordFailedSource = ( source: SavedSource, @@ -279,6 +282,7 @@ export async function refreshNodeSnapshot( deletedNodes, }); + renameMap = composeNodeNameRenameMaps(renameMap, merged.renameMap); currentNodes = merged.nodes; usedUrlFetch = true; refreshedSourceCount += 1; @@ -313,6 +317,7 @@ export async function refreshNodeSnapshot( deletedNodes, }); + renameMap = composeNodeNameRenameMaps(renameMap, merged.renameMap); currentNodes = merged.nodes; refreshedSourceCount += 1; refreshedStaticSourceCount += 1; @@ -354,6 +359,7 @@ export async function refreshNodeSnapshot( return { nodes: currentNodes, + renameMap, subscriptionInfo, savedSources: refreshedSavedSources, attemptedUrlFetch, diff --git a/packages/ui/src/components/auth/user-menu.test.ts b/packages/ui/src/components/auth/user-menu.test.ts index 9a38d5b7..f5cff0c5 100644 --- a/packages/ui/src/components/auth/user-menu.test.ts +++ b/packages/ui/src/components/auth/user-menu.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ refContains: vi.fn(() => false), stateSetter: vi.fn(), stateOverride: undefined as boolean | undefined, + toast: vi.fn(), useConfigStore: Object.assign(vi.fn(), { getState: vi.fn(() => ({ sources: [] })) }), userState: { fetchUser: vi.fn(), @@ -97,6 +98,8 @@ vi.mock("@subboost/ui/components/ui/safe-image", () => ({ SafeImage: (props: any) => React.createElement("span", null, props.alt, props.fallback), })); +vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); + vi.mock("@subboost/ui/store/config-store/auth-handoff", () => ({ captureAuthConfigHandoff: mocks.captureAuthConfigHandoff, })); @@ -232,4 +235,33 @@ describe("UserMenu", () => { expect(mocks.stateSetter).toHaveBeenCalledWith(false); expect(window.location.href).toBe("/"); }); + + it("keeps the current page and shows an error when logout fails", async () => { + mocks.stateOverride = true; + mocks.logout.mockRejectedValueOnce(new Error("Session service unavailable.")); + mocks.userState = { + fetchUser: mocks.fetchUser, + isLoading: false, + logout: mocks.logout, + user: { + avatarUrl: null, + isAdmin: false, + isBanned: false, + name: "Alice", + username: "alice", + trustLevel: 1, + subscriptionCount: 1, + quota: { maxSubscriptions: 5 }, + }, + }; + + renderToStaticMarkup(React.createElement(UserMenu)); + const logoutItem = mocks.dropdownItems.find((item) => textOf(item.children).includes("退出登录")); + await logoutItem.onSelect(); + + expect(window.location.href).toBe(""); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "退出登录失败", variant: "destructive" }) + ); + }); }); diff --git a/packages/ui/src/components/auth/user-menu.tsx b/packages/ui/src/components/auth/user-menu.tsx index 1341dcc7..01f44f0f 100644 --- a/packages/ui/src/components/auth/user-menu.tsx +++ b/packages/ui/src/components/auth/user-menu.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from "@subboost/ui/components/ui/dropdown-menu"; import { SafeImage } from "@subboost/ui/components/ui/safe-image"; +import { toast } from "@subboost/ui/components/ui/toaster"; import { captureAuthConfigHandoff } from "@subboost/ui/store/config-store/auth-handoff"; import { useConfigStore } from "@subboost/ui/store/config-store"; import { useUserStore } from "@subboost/ui/store/user-store"; @@ -39,9 +40,17 @@ export function UserMenu({ privilegedMenuItem }: { privilegedMenuItem?: AccountM }, [fetchUser]); const handleLogout = async () => { - if (user) await userLogout(); - setIsOpen(false); - window.location.href = "/"; + try { + if (user) await userLogout(); + setIsOpen(false); + window.location.href = "/"; + } catch (error) { + toast({ + title: "退出登录失败", + description: error instanceof Error ? error.message : "会话服务暂时不可用,请稍后重试。", + variant: "destructive", + }); + } }; const isLoading = userLoading && !user; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts index a3a5e4c3..44a293d9 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts @@ -123,12 +123,13 @@ vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); vi.mock("@subboost/ui/components/ui/confirm-dialog", () => ({ confirmDialog: mocks.confirmDialog })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ PROXY_GROUP_MODULES: [ - { id: "auto", name: "Auto" }, - { id: "fallback", name: "Fallback" }, + { id: "auto", name: "⚡ 自动选择", emoji: "⚡" }, + { id: "adult", name: "🔞 成人内容", emoji: "🔞" }, ], })); vi.mock("@subboost/core/proxy-group-name", () => ({ - resolveProxyGroupModuleName: (module: { name: string }, override?: string) => override || module.name, + resolveProxyGroupModuleName: (module: { emoji: string; name: string }, override?: string) => + override ? `${module.emoji} ${override}` : module.name, splitLeadingEmoji: (name: string) => { const match = name.trim().match(/^(\S+)\s+(.+)$/); if (!match || /[A-Za-z0-9\u4e00-\u9fff]/.test(match[1])) { @@ -231,6 +232,7 @@ describe("DialerProxyGroupsSection", () => { nodes, dialerProxyGroups: [groupA, groupB], customProxyGroups: [{ name: "Custom" }], + enabledProxyGroups: ["auto"], proxyGroupNameOverrides: { auto: "Auto Override" }, addDialerProxyGroup: vi.fn(), removeDialerProxyGroup: vi.fn(), @@ -292,6 +294,27 @@ describe("DialerProxyGroupsSection", () => { }); }); + it("offers only enabled custom and effective built-in groups as new relays", () => { + mocks.store.customProxyGroups = [ + { name: "Migrated Custom" }, + { name: "Disabled Custom", enabled: false }, + { name: "⚡ Auto Override", enabled: false }, + ]; + mocks.store.enabledProxyGroups = ["auto"]; + mocks.store.proxyGroupNameOverrides = { + auto: "Auto Override", + adult: "Disabled Adult Override", + }; + + const { html } = renderSection({ 0: new Set(["g-a"]) }); + + expect(html).toContain("Migrated Custom"); + expect(html).toContain("Auto Override"); + expect(html).not.toContain("Disabled Custom"); + expect(html).not.toContain("Disabled Adult Override"); + expect(html.match(/⚡ Auto Override/g)).toHaveLength(1); + }); + it("adds custom groups, rejects duplicates, and records interactions", () => { const { setters } = renderSection({ 1: true, 2: { emoji: "🧩", name: "New Dialer" } }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx index 8c3678a5..8750256e 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx @@ -20,6 +20,7 @@ import { toast } from "@subboost/ui/components/ui/toaster"; import { DEFAULT_LOAD_BALANCE_STRATEGY, type ProxyGroupGroupType } from "@subboost/core/types/config"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { getValidDialerRelayGroupNames } from "@subboost/core/subscription/dialer-relay-group-names"; import { resolveNodeNameFilter } from "@subboost/core/subscription/node-name-filter"; import { cn } from "@subboost/ui/lib/utils"; import { useConfigStore, PRESET_RELAY_NAMES } from "@subboost/ui/store/config-store"; @@ -59,6 +60,7 @@ export function DialerProxyGroupsSection({ nodeNameFilter, dialerProxyGroups, customProxyGroups, + enabledProxyGroups, proxyGroupNameOverrides, addDialerProxyGroup, removeDialerProxyGroup, @@ -97,7 +99,10 @@ export function DialerProxyGroupsSection({ ); const rawNodeNameSet = React.useMemo(() => new Set(nodes.map((node) => node.name)), [nodes]); const effectiveNodeNameSet = React.useMemo(() => new Set(effectiveNodes.map((node) => node.name)), [effectiveNodes]); - + const validRelayGroupNames = React.useMemo( + () => getValidDialerRelayGroupNames({ customProxyGroups, enabledGroups: enabledProxyGroups, proxyGroupNameOverrides }), + [customProxyGroups, enabledProxyGroups, proxyGroupNameOverrides], + ); const toggleDialerGroupExpand = (groupId: string) => { setExpandedDialerGroups((prev) => { const next = new Set(prev); @@ -170,20 +175,17 @@ export function DialerProxyGroupsSection({ })) as DialerSelectableNode[]; const availableProxyGroups = [ - ...PROXY_GROUP_MODULES.map( - (module) => - ({ - name: resolveModuleFullName(module), - type: "内置组", - }) as DialerSelectableNode, - ), + ...PROXY_GROUP_MODULES.map((module) => ({ + name: resolveModuleFullName(module), + type: "内置组", + }) as DialerSelectableNode).filter((group) => validRelayGroupNames.has(group.name)), ...customProxyGroups .filter((group) => group.enabled !== false) .map((group) => ({ name: typeof group.name === "string" ? group.name.trim() : "", type: "自定义组", })) - .filter((group) => group.name), + .filter((group) => validRelayGroupNames.has(group.name)), ]; // 中转组允许选择 DIRECT(直连)作为“入口” @@ -245,7 +247,6 @@ export function DialerProxyGroupsSection({ const visibleTargetNodes = targetSearchKeyword ? availableTargetNodes.filter((node) => node.name.toLowerCase().includes(targetSearchKeyword)) : availableTargetNodes; - const commitRename = () => { const nextName = buildProxyGroupName(editingDialerGroupDraft); if (!nextName) return; diff --git a/packages/ui/src/store/config-store/source-actions-multiple.test.ts b/packages/ui/src/store/config-store/source-actions-multiple.test.ts index 1ed85044..68fb7c30 100644 --- a/packages/ui/src/store/config-store/source-actions-multiple.test.ts +++ b/packages/ui/src/store/config-store/source-actions-multiple.test.ts @@ -257,6 +257,7 @@ describe("createSourceActions parseMultipleSources", () => { }); it("merges duplicate parsed nodes and prunes stale listener ports and dialer nodes", async () => { + const migratedCustomGroupName = "🧩 筛选组 美国"; const duplicate = node("Duplicate", { server: "same.example.com", _originName: "Duplicate", @@ -270,6 +271,12 @@ describe("createSourceActions parseMultipleSources", () => { ]; const { actions, getState } = createHarness({ sources, + enabledProxyGroups: ["auto"], + customProxyGroups: [ + { id: "legacy-us", name: migratedCustomGroupName, emoji: "🧩", enabled: true, groupType: "select" }, + { id: "disabled", name: "🧩 已停用", emoji: "🧩", enabled: false, groupType: "select" }, + ], + proxyGroupNameOverrides: { auto: "自定义自动" }, listenerPorts: { Duplicate: 41000, Stale: 41001, @@ -279,8 +286,15 @@ describe("createSourceActions parseMultipleSources", () => { id: "dialer-1", name: "Relay", type: "select", - relayNodes: ["Duplicate", "DIRECT", "Stale"], - targetNodes: ["Duplicate", "Stale"], + relayNodes: [ + ` ${migratedCustomGroupName} `, + "⚡ 自定义自动", + "Duplicate", + "DIRECT", + "Stale", + "🧩 已停用", + ], + targetNodes: [migratedCustomGroupName, "⚡ 自定义自动", "Duplicate", "Stale"], }, ], }); @@ -295,7 +309,7 @@ describe("createSourceActions parseMultipleSources", () => { }); expect(getState().listenerPorts).toEqual({ Duplicate: 41000 }); expect(getState().dialerProxyGroups[0]).toMatchObject({ - relayNodes: ["Duplicate", "DIRECT"], + relayNodes: [migratedCustomGroupName, "⚡ 自定义自动", "Duplicate", "DIRECT"], targetNodes: ["Duplicate"], }); expect(getState().parseErrors).toEqual(["源 #1: first warning"]); diff --git a/packages/ui/src/store/config-store/source-actions-relay-groups.test.ts b/packages/ui/src/store/config-store/source-actions-relay-groups.test.ts new file mode 100644 index 00000000..c28dde3c --- /dev/null +++ b/packages/ui/src/store/config-store/source-actions-relay-groups.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + createHarness, + node, + resetSourceActionMocks, + source, +} from "./source-actions.test-utils"; + +describe("createSourceActions relay group references", () => { + beforeEach(resetSourceActionMocks); + + it("keeps enabled custom and effective built-in groups when source removal filters dialer relays", () => { + const migratedCustomGroupName = "🧩 筛选组 美国"; + const { actions, getState } = createHarness({ + sources: [source({ id: "s1" }), source({ id: "s2" })], + nodes: [ + node("Removed", { _sourceIds: ["s1"] }), + node("Remaining", { _sourceIds: ["s2"] }), + ], + enabledProxyGroups: ["auto"], + customProxyGroups: [ + { id: "legacy-us", name: migratedCustomGroupName, emoji: "🧩", enabled: true, groupType: "select" }, + { id: "disabled", name: "🧩 已停用", emoji: "🧩", enabled: false, groupType: "select" }, + ], + proxyGroupNameOverrides: { auto: "自定义自动", adult: "私密" }, + dialerProxyGroups: [ + { + id: "dialer-1", + name: "Relay", + relayNodes: [ + migratedCustomGroupName, + ` ${migratedCustomGroupName} `, + "⚡ 自定义自动", + "DIRECT", + "Removed", + "Remaining", + "🧩 已停用", + "🧩 不存在", + "🔞 私密", + ], + targetNodes: [migratedCustomGroupName, "⚡ 自定义自动", "Removed", "Remaining"], + }, + ], + }); + + actions.setSources([source({ id: "s2" })]); + + expect(getState().dialerProxyGroups[0]).toMatchObject({ + relayNodes: [migratedCustomGroupName, "⚡ 自定义自动", "DIRECT", "Remaining"], + targetNodes: ["Remaining"], + }); + }); +}); diff --git a/packages/ui/src/store/config-store/source-actions.test.ts b/packages/ui/src/store/config-store/source-actions.test.ts index 34700574..3cfa181c 100644 --- a/packages/ui/src/store/config-store/source-actions.test.ts +++ b/packages/ui/src/store/config-store/source-actions.test.ts @@ -166,6 +166,7 @@ describe("createSourceActions", () => { }); it("imports proxy-provider URL sources without fetching node content", async () => { + const migratedCustomGroupName = "🧩 筛选组 美国"; const { actions, getState } = createHarness({ sources: [ source({ @@ -178,13 +179,19 @@ describe("createSourceActions", () => { }), ], nodes: [node("Provider Node", { _sourceIds: ["s1"], _originName: "Provider Node" }), node("Manual")], + enabledProxyGroups: ["auto"], + customProxyGroups: [ + { id: "legacy-us", name: migratedCustomGroupName, emoji: "🧩", enabled: true, groupType: "select" }, + { id: "disabled", name: "🧩 已停用", emoji: "🧩", enabled: false, groupType: "select" }, + ], + proxyGroupNameOverrides: { auto: "自定义自动" }, listenerPorts: { "Provider Node": 41000, Manual: 41001 }, dialerProxyGroups: [ { id: "dialer-1", name: "Relay", - relayNodes: ["DIRECT", "Provider Node", "Manual"], - targetNodes: ["Provider Node"], + relayNodes: [migratedCustomGroupName, "⚡ 自定义自动", "DIRECT", "Provider Node", "Manual", "🧩 已停用"], + targetNodes: [migratedCustomGroupName, "⚡ 自定义自动", "Provider Node"], }, ], }); @@ -196,7 +203,7 @@ describe("createSourceActions", () => { expect(getState().nodes.map((item: ParsedNode) => item.name)).toEqual(["Manual"]); expect(getState().listenerPorts).toEqual({ Manual: 41001 }); expect(getState().dialerProxyGroups[0]).toMatchObject({ - relayNodes: ["DIRECT", "Manual"], + relayNodes: [migratedCustomGroupName, "⚡ 自定义自动", "DIRECT", "Manual"], targetNodes: [], }); expect(getState().sources[0]).toMatchObject({ @@ -411,11 +418,31 @@ describe("createSourceActions", () => { source({ id: "s2", type: "yaml", content: "proxies: []", parsed: true }), ], nodes: [node("OLD-Fresh Renamed", { _originName: "Fresh Renamed", _sourceIds: ["s1"] })], + listenerPorts: { "OLD-Fresh Renamed": 12000 }, + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + relayNodes: ["OLD-Fresh Renamed"], + targetNodes: ["OLD-Fresh Renamed"], + }, + ], + proxyGroupAdvanced: { + auto: { memberOrder: [{ kind: "node", name: "OLD-Fresh Renamed" }] }, + }, }); await actions.parseSingleSource("s1"); expect(getState().nodes).toEqual([expect.objectContaining({ name: "Fresh Renamed" })]); + expect(getState().listenerPorts).toEqual({ "Fresh Renamed": 12000 }); + expect(getState().dialerProxyGroups[0]).toMatchObject({ + relayNodes: ["Fresh Renamed"], + targetNodes: ["Fresh Renamed"], + }); + expect(getState().proxyGroupAdvanced).toEqual({ + auto: { memberOrder: [{ kind: "node", name: "Fresh Renamed" }] }, + }); expect(getState().parseErrors).toEqual([]); expect(getState().sources).toEqual([ expect.objectContaining({ @@ -494,9 +521,16 @@ describe("createSourceActions", () => { }); it("keeps listener ports and dialer groups aligned after a single source parse", async () => { + const migratedCustomGroupName = "🧩 筛选组 美国"; mocks.parseSubscription.mockReturnValueOnce(parseResult([node("Fresh"), node("Relay Target")])); const { actions, getState } = createHarness({ sources: [source({ id: "s1", type: "yaml", content: "proxies: []" })], + enabledProxyGroups: ["auto"], + customProxyGroups: [ + { id: "legacy-us", name: migratedCustomGroupName, emoji: "🧩", enabled: true, groupType: "select" }, + { id: "disabled", name: "🧩 已停用", emoji: "🧩", enabled: false, groupType: "select" }, + ], + proxyGroupNameOverrides: { auto: "自定义自动" }, listenerPorts: { Fresh: 41000, Stale: 41001, @@ -506,8 +540,17 @@ describe("createSourceActions", () => { { id: "dialer-1", name: "Relay", - relayNodes: ["DIRECT", "Fresh", "Fresh", "Stale", "Relay Target"], - targetNodes: ["Fresh", "Stale", "Relay Target"], + relayNodes: [ + migratedCustomGroupName, + "⚡ 自定义自动", + "DIRECT", + "Fresh", + "Fresh", + "Stale", + "Relay Target", + "🧩 已停用", + ], + targetNodes: [migratedCustomGroupName, "⚡ 自定义自动", "Fresh", "Stale", "Relay Target"], }, ], }); @@ -517,7 +560,7 @@ describe("createSourceActions", () => { expect(getState().nodes.map((item: ParsedNode) => item.name)).toEqual(["Fresh", "Relay Target"]); expect(getState().listenerPorts).toEqual({ Fresh: 41000 }); expect(getState().dialerProxyGroups[0]).toMatchObject({ - relayNodes: ["DIRECT", "Fresh", "Relay Target"], + relayNodes: [migratedCustomGroupName, "⚡ 自定义自动", "DIRECT", "Fresh", "Relay Target"], targetNodes: ["Fresh", "Relay Target"], }); expect(getState().sources[0]).toMatchObject({ diff --git a/packages/ui/src/store/config-store/source-actions.ts b/packages/ui/src/store/config-store/source-actions.ts index 4ecd5b94..a598d198 100644 --- a/packages/ui/src/store/config-store/source-actions.ts +++ b/packages/ui/src/store/config-store/source-actions.ts @@ -21,6 +21,8 @@ import { type SubscriptionImportErrorInfo, } from "@subboost/core/subscription/import-error"; import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscription/imported-node-controls"; +import { getValidDialerRelayGroupNames } from "@subboost/core/subscription/dialer-relay-group-names"; +import { reconcileNodeNameReferences } from "@subboost/core/subscription/node-name-references"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import type { ConfigActions, SubscriptionSource } from "./definitions"; import { @@ -64,14 +66,30 @@ function mergeNodeSourceIds(existing: ParsedNode, sourceIds: Set): Parse return { ...existingRecord, [SOURCE_IDS_KEY]: Array.from(sourceIds) } as unknown as ParsedNode; } +function filterAvailableNames(names: string[], availableNames: ReadonlySet): string[] { + const seen = new Set(); + return names.map((name) => name.trim()).filter((name) => { + if (!name || seen.has(name)) return false; + seen.add(name); + return availableNames.has(name); + }); +} + function filterDialerProxyGroupsByAvailableNames( dialerProxyGroups: StoreState["dialerProxyGroups"], - availableNames: Set + availableNames: Set, + config: Pick ): StoreState["dialerProxyGroups"] { + const availableGroupNames = getValidDialerRelayGroupNames({ + customProxyGroups: config.customProxyGroups, + enabledGroups: config.enabledProxyGroups, + proxyGroupNameOverrides: config.proxyGroupNameOverrides, + }); + const availableRelayNames = new Set(["DIRECT", ...availableNames, ...availableGroupNames]); return dialerProxyGroups.map((group) => ({ ...group, - relayNodes: group.relayNodes.filter((name) => name === "DIRECT" || availableNames.has(name)), - targetNodes: group.targetNodes.filter((name) => availableNames.has(name)), + relayNodes: filterAvailableNames(group.relayNodes, availableRelayNames), + targetNodes: filterAvailableNames(group.targetNodes, availableNames), })); } @@ -116,7 +134,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate nextListenerPorts[name] = port; } - const nextDialerProxyGroups = filterDialerProxyGroupsByAvailableNames(state.dialerProxyGroups, availableNames); + const nextDialerProxyGroups = filterDialerProxyGroupsByAvailableNames(state.dialerProxyGroups, availableNames, state); return { sources, @@ -236,7 +254,9 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate nextListenerPorts[name] = port; } - const nextDialerProxyGroups = filterDialerProxyGroupsByAvailableNames(state.dialerProxyGroups, availableNames); + const nextDialerProxyGroups = filterDialerProxyGroupsByAvailableNames( + state.dialerProxyGroups, availableNames, state + ); return { nodes: baseNodes, @@ -316,44 +336,21 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate }); const nextNodes = merged.nodes; - const availableNames = new Set(nextNodes.map((n) => n.name)); - const nextListenerPorts: Record = {}; - for (const [name, port] of Object.entries(state.listenerPorts)) { - const mappedName = merged.renameMap.get(name) ?? name; - if (!availableNames.has(mappedName)) continue; - if (typeof port !== "number" || !Number.isInteger(port)) continue; - nextListenerPorts[mappedName] = port; - } - - const replaceNames = (list: string[], opts?: { keepDirect?: boolean }) => { - const out: string[] = []; - const seen = new Set(); - for (const item of list) { - if (opts?.keepDirect && item === "DIRECT") { - if (!seen.has(item)) out.push(item); - seen.add(item); - continue; - } - const next = merged.renameMap.get(item) ?? item; - if (seen.has(next)) continue; - seen.add(next); - out.push(next); - } - return out; - }; - - const nextDialerProxyGroups = state.dialerProxyGroups.map((g) => { - const relayNodes = replaceNames(g.relayNodes, { keepDirect: true }).filter( - (n) => n === "DIRECT" || availableNames.has(n) - ); - const targetNodes = replaceNames(g.targetNodes).filter((n) => availableNames.has(n)); - return { ...g, relayNodes, targetNodes }; - }); + const reconciledReferences = reconcileNodeNameReferences( + { + listenerPorts: state.listenerPorts, + dialerProxyGroups: state.dialerProxyGroups, + proxyGroupAdvanced: state.proxyGroupAdvanced, + customProxyGroups: state.customProxyGroups, + enabledGroups: state.enabledProxyGroups, + proxyGroupNameOverrides: state.proxyGroupNameOverrides, + }, + { nodes: nextNodes, renameMap: merged.renameMap } + ); return { nodes: nextNodes, - listenerPorts: nextListenerPorts, - dialerProxyGroups: nextDialerProxyGroups, + ...reconciledReferences, sources: state.sources.map((s) => s.id === sourceId ? { @@ -660,7 +657,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate if (typeof port !== "number" || !Number.isInteger(port)) continue; nextListenerPorts[name] = port; } - const nextDialerProxyGroups = filterDialerProxyGroupsByAvailableNames(state.dialerProxyGroups, availableNames); + const nextDialerProxyGroups = filterDialerProxyGroupsByAvailableNames(state.dialerProxyGroups, availableNames, state); return { nodes: normalized, diff --git a/packages/ui/src/store/user-store.test.ts b/packages/ui/src/store/user-store.test.ts index 0e05a33e..ccb06b26 100644 --- a/packages/ui/src/store/user-store.test.ts +++ b/packages/ui/src/store/user-store.test.ts @@ -100,14 +100,40 @@ describe("user store", () => { expect(useUserStore.getState()).toEqual(expect.objectContaining({ user: null, error: null })); }); - it("keeps logout failures contained and leaves missing users unchanged for local flag updates", async () => { - vi.spyOn(console, "error").mockImplementationOnce(() => undefined); - vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("logout failed"))); + it("keeps the authenticated user when logout persistence fails", async () => { + const currentUser = user(); + useUserStore.setState({ user: currentUser, error: null }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce({ + ok: false, + status: 503, + json: vi.fn(async () => ({ error: "Session service unavailable." })), + }) + ); - await useUserStore.getState().logout(); - expect(useUserStore.getState().user).toBeNull(); + await expect(useUserStore.getState().logout()).rejects.toThrow("Session service unavailable."); + expect(useUserStore.getState()).toEqual( + expect.objectContaining({ user: currentUser, error: "Session service unavailable." }) + ); useUserStore.getState().updateAiAssistantEnabled(true); - expect(useUserStore.getState().user).toBeNull(); + expect(useUserStore.getState().user?.aiAssistantEnabled).toBe(true); + }); + + it("keeps the user and reports HTTP status when logout returns invalid JSON", async () => { + const currentUser = user(); + useUserStore.setState({ user: currentUser, error: null }); + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 503, + json: vi.fn().mockRejectedValueOnce(new SyntaxError("Invalid JSON")), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(useUserStore.getState().logout()).rejects.toThrow("退出登录失败 (HTTP 503)"); + expect(fetchMock).toHaveBeenCalledWith("/api/auth/logout", { method: "POST" }); + expect(useUserStore.getState().user).toBe(currentUser); + expect(useUserStore.getState().error).toBe("退出登录失败 (HTTP 503)"); }); }); diff --git a/packages/ui/src/store/user-store.ts b/packages/ui/src/store/user-store.ts index db170b90..ab8085a6 100644 --- a/packages/ui/src/store/user-store.ts +++ b/packages/ui/src/store/user-store.ts @@ -91,10 +91,20 @@ export const useUserStore = create((set) => ({ logout: async () => { try { - await fetch("/api/auth/logout", { method: "POST" }); - set({ user: null }); + const response = await fetch("/api/auth/logout", { method: "POST" }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { error?: unknown }; + const message = + typeof payload.error === "string" && payload.error.trim() + ? payload.error + : `退出登录失败 (HTTP ${response.status})`; + throw new Error(message); + } + set({ user: null, error: null }); } catch (error) { - console.error("Logout error:", error); + const message = error instanceof Error ? error.message : "退出登录失败"; + set({ error: message }); + throw error instanceof Error ? error : new Error(message); } },