diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d2ab02..2113e97 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,8 +2,26 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTypescript from "eslint-config-next/typescript"; +const accessibilityRules = { + "jsx-a11y/click-events-have-key-events": "error", + "jsx-a11y/label-has-associated-control": "error", + "jsx-a11y/no-static-element-interactions": "error", +}; + +const nextVitalsWithAccessibility = nextVitals.map((config) => + config.name === "next" + ? { + ...config, + rules: { + ...config.rules, + ...accessibilityRules, + }, + } + : config +); + export default defineConfig([ - ...nextVitals, + ...nextVitalsWithAccessibility, ...nextTypescript, { linterOptions: { diff --git a/local/Dockerfile b/local/Dockerfile index c361758..9a859ff 100644 --- a/local/Dockerfile +++ b/local/Dockerfile @@ -2,14 +2,27 @@ FROM node:22-alpine AS deps WORKDIR /package RUN apk add --no-cache openssl +COPY package.json package-lock.json ./ COPY packages/core packages/core COPY packages/server-core packages/server-core COPY packages/ui packages/ui COPY packages/config packages/config -COPY local/package*.json local/ +COPY local/package.json local/package.json -WORKDIR /package/local -RUN npm install +RUN --mount=type=cache,target=/root/.npm,sharing=locked \ + set -eu; \ + attempt=1; \ + max_attempts=3; \ + until npm ci; do \ + if [ "$attempt" -ge "$max_attempts" ]; then \ + echo "npm ci failed after ${max_attempts} attempts" >&2; \ + exit 1; \ + fi; \ + delay=$((attempt * 10)); \ + echo "npm ci attempt ${attempt} failed; retrying in ${delay}s" >&2; \ + sleep "$delay"; \ + attempt=$((attempt + 1)); \ + done FROM node:22-alpine AS builder WORKDIR /package @@ -24,8 +37,7 @@ ENV APP_BUILD_SHA=${APP_BUILD_SHA} ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN} -COPY --from=deps /package/local/node_modules local/node_modules -RUN ln -s local/node_modules node_modules +COPY --from=deps /package/node_modules node_modules COPY packages/core packages/core COPY packages/server-core packages/server-core COPY packages/ui packages/ui @@ -35,6 +47,9 @@ COPY local local WORKDIR /package/local RUN npm run build +FROM deps AS production-deps +RUN npm prune --omit=dev + FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production @@ -48,14 +63,14 @@ ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN} RUN apk add --no-cache openssl -COPY --from=builder /package/local/.next/standalone ./ -COPY --from=deps /package/local/node_modules local/node_modules -COPY --from=builder /package/local/.next/static local/.next/static -COPY --from=builder /package/local/public local/public -COPY --from=builder /package/local/prisma.config.ts local/prisma.config.ts -COPY --from=builder /package/local/prisma local/prisma -RUN if [ ! -e node_modules ] && [ -d local/node_modules ]; then ln -s local/node_modules node_modules; fi +COPY --chown=node:node --from=builder /package/local/.next/standalone ./ +COPY --chown=node:node --from=production-deps /package/node_modules ./node_modules +COPY --chown=node:node --from=builder /package/local/.next/static local/.next/static +COPY --chown=node:node --from=builder /package/local/public local/public +COPY --chown=node:node --from=builder /package/local/prisma.config.ts local/prisma.config.ts +COPY --chown=node:node --from=builder /package/local/prisma local/prisma WORKDIR /app/local +USER node EXPOSE 3000 -CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"] +CMD ["sh", "-c", "../node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"] diff --git a/local/app/api/auth/local-auth-routes.test.ts b/local/app/api/auth/local-auth-routes.test.ts index b419970..908b4c0 100644 --- a/local/app/api/auth/local-auth-routes.test.ts +++ b/local/app/api/auth/local-auth-routes.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit"; const mocks = vi.hoisted(() => ({ bcryptCompare: vi.fn(), @@ -49,6 +50,7 @@ async function readJson(response: Response) { describe("local auth and health routes", () => { beforeEach(() => { vi.clearAllMocks(); + clearLocalRateLimitsForTests(); }); it("logs in a valid local admin and sets the session cookie", async () => { @@ -101,6 +103,15 @@ describe("local auth and health routes", () => { status: 401, body: { error: "Invalid username or password.", code: "UNAUTHORIZED" }, }); + + await expect(readJson(await POST(new Request("https://local.test/api/auth/login", { + method: "POST", + headers: { "content-length": String(64 * 1024 + 1) }, + body: "{}", + })))).resolves.toEqual({ + status: 413, + body: { error: "Request body is too large.", code: "PAYLOAD_TOO_LARGE" }, + }); }); it("logs out by clearing the session cookie", async () => { diff --git a/local/app/api/auth/login/route.ts b/local/app/api/auth/login/route.ts index 949a36b..795c604 100644 --- a/local/app/api/auth/login/route.ts +++ b/local/app/api/auth/login/route.ts @@ -1,15 +1,44 @@ import bcrypt from "bcryptjs"; import { NextResponse } from "next/server"; -import { apiError, getStringField, readJsonBody } from "@local/lib/http"; +import { apiError, getStringField, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http"; import { prisma } from "@local/lib/prisma"; import { sessionCookieOptions, signSession, SESSION_COOKIE } from "@local/lib/session"; +import { + consumeLocalRateLimit, + getTrustedClientRateLimitKey, + hashLocalRateLimitKey, + localRateLimitResponse, + resetLocalRateLimit, +} from "@local/lib/rate-limit"; + +const LOGIN_WINDOW_MS = 15 * 60 * 1000; export async function POST(request: Request) { - const body = await readJsonBody(request); - if (!body) return apiError("Invalid JSON body.", "BAD_REQUEST", 400); + const clientKey = getTrustedClientRateLimitKey(request); + if (clientKey) { + const clientLimit = consumeLocalRateLimit("auth-login-client", clientKey, { + limit: 30, + windowMs: LOGIN_WINDOW_MS, + }); + if (!clientLimit.allowed) { + return localRateLimitResponse("Too many login attempts. Try again later.", clientLimit.retryAfterSeconds); + } + } + + const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.small); + if (!parsedBody.ok) return jsonBodyError(parsedBody); + const body = parsedBody.value; const username = getStringField(body, "username"); const password = getStringField(body, "password"); + const usernameLimitKey = hashLocalRateLimitKey(username.toLowerCase() || "missing"); + const usernameLimit = consumeLocalRateLimit("auth-login-username", usernameLimitKey, { + limit: 8, + windowMs: LOGIN_WINDOW_MS, + }); + if (!usernameLimit.allowed) { + return localRateLimitResponse("Too many login attempts. Try again later.", usernameLimit.retryAfterSeconds); + } const admin = username ? await prisma.localAdmin.findUnique({ where: { username }, select: { id: true, username: true, passwordHash: true } }) : null; @@ -18,6 +47,8 @@ export async function POST(request: Request) { return apiError("Invalid username or password.", "UNAUTHORIZED", 401); } + resetLocalRateLimit("auth-login-username", usernameLimitKey); + await prisma.localAdmin.update({ where: { id: admin.id }, data: { lastLoginAt: new Date() } }); const response = NextResponse.json({ success: true, user: { id: admin.id, username: admin.username } }); response.cookies.set(SESSION_COOKIE, await signSession({ adminId: admin.id, username: admin.username }), sessionCookieOptions()); diff --git a/local/app/api/cron/update-subscriptions/route.ts b/local/app/api/cron/update-subscriptions/route.ts index 44cc195..f36242d 100644 --- a/local/app/api/cron/update-subscriptions/route.ts +++ b/local/app/api/cron/update-subscriptions/route.ts @@ -2,15 +2,44 @@ import { NextRequest } from "next/server"; import { json } from "@local/lib/http"; import { requireLocalCronAuth } from "@local/lib/cron-auth"; import { runLocalSubscriptionAutoUpdateCron } from "@local/lib/auto-update-service"; +import { + acquireLocalJobLease, + JobLeaseLostError, + releaseLocalJobLease, + startLocalJobLeaseHeartbeat, + type LocalJobLease, +} from "@local/lib/job-lease"; + +const LEASE_NAME = "local-subscription-auto-update"; +const LEASE_MS = 5 * 60 * 1000; +const HEARTBEAT_MS = 60 * 1000; export async function POST(request: NextRequest) { const authError = requireLocalCronAuth(request); if (authError) return authError; - const summary = await runLocalSubscriptionAutoUpdateCron(); - return json({ - success: true, - ...summary, - timestamp: new Date().toISOString(), - }); + let lease: LocalJobLease | null = null; + let heartbeat: ReturnType | null = null; + try { + lease = await acquireLocalJobLease({ name: LEASE_NAME, leaseMs: LEASE_MS }); + if (!lease) return json({ success: true, skipped: true, reason: "already_running" }); + + heartbeat = startLocalJobLeaseHeartbeat({ lease, leaseMs: LEASE_MS, intervalMs: HEARTBEAT_MS }); + const summary = await runLocalSubscriptionAutoUpdateCron(new Date(), { + assertLease: heartbeat.assertOwned, + }); + return json({ + success: true, + ...summary, + timestamp: new Date().toISOString(), + }); + } catch (error) { + if (error instanceof JobLeaseLostError) { + return json({ error: "Local subscription update lease lost.", code: "JOB_LEASE_LOST" }, 503); + } + throw error; + } finally { + await heartbeat?.stop(); + if (lease) await releaseLocalJobLease(lease).catch(() => undefined); + } } diff --git a/local/app/api/settings/source-import/route.test.ts b/local/app/api/settings/source-import/route.test.ts new file mode 100644 index 0000000..9e42244 --- /dev/null +++ b/local/app/api/settings/source-import/route.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getCurrentAdmin: vi.fn(), + prisma: { + localAdmin: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@local/lib/auth", () => ({ + getCurrentAdmin: mocks.getCurrentAdmin, +})); + +vi.mock("@local/lib/prisma", () => ({ + prisma: mocks.prisma, +})); + +import { GET, PATCH } from "./route"; + +async function readJson(response: Response) { + return { status: response.status, body: await response.json() }; +} + +function patchRequest(body: string) { + return new Request("https://local.test/api/settings/source-import", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body, + }); +} + +describe("local source import settings route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCurrentAdmin.mockResolvedValue({ id: "admin-1", username: "admin" }); + }); + + it("requires the local administrator for reads and writes", async () => { + mocks.getCurrentAdmin.mockResolvedValue(null); + + await expect(readJson(await GET())).resolves.toEqual({ + status: 401, + body: { error: "Authentication required.", code: "UNAUTHORIZED" }, + }); + await expect(readJson(await PATCH(patchRequest("{}")))).resolves.toEqual({ + status: 401, + body: { error: "Authentication required.", code: "UNAUTHORIZED" }, + }); + expect(mocks.prisma.localAdmin.findUnique).not.toHaveBeenCalled(); + expect(mocks.prisma.localAdmin.update).not.toHaveBeenCalled(); + }); + + it("returns the persisted setting", async () => { + mocks.prisma.localAdmin.findUnique.mockResolvedValueOnce({ + allowUnsafeSubscriptionSources: true, + }); + + await expect(readJson(await GET())).resolves.toEqual({ + status: 200, + body: { allowUnsafeSubscriptionSources: true }, + }); + expect(mocks.prisma.localAdmin.findUnique).toHaveBeenCalledWith({ + where: { id: "admin-1" }, + select: { allowUnsafeSubscriptionSources: true }, + }); + }); + + it("updates the setting with a boolean value", async () => { + mocks.prisma.localAdmin.update.mockResolvedValueOnce({ + allowUnsafeSubscriptionSources: true, + }); + + await expect( + readJson(await PATCH(patchRequest(JSON.stringify({ allowUnsafeSubscriptionSources: true })))) + ).resolves.toEqual({ + status: 200, + body: { allowUnsafeSubscriptionSources: true }, + }); + expect(mocks.prisma.localAdmin.update).toHaveBeenCalledWith({ + where: { id: "admin-1" }, + data: { allowUnsafeSubscriptionSources: true }, + select: { allowUnsafeSubscriptionSources: true }, + }); + }); + + it("rejects malformed JSON and non-boolean values", async () => { + await expect(readJson(await PATCH(patchRequest("{")))).resolves.toEqual({ + status: 400, + body: { error: "Invalid JSON body.", code: "BAD_REQUEST" }, + }); + await expect( + readJson(await PATCH(patchRequest(JSON.stringify({ allowUnsafeSubscriptionSources: "yes" })))) + ).resolves.toEqual({ + status: 400, + body: { + error: "allowUnsafeSubscriptionSources must be a boolean.", + code: "VALIDATION_ERROR", + }, + }); + expect(mocks.prisma.localAdmin.update).not.toHaveBeenCalled(); + }); + + it("rejects settings bodies above 64 KiB", async () => { + const response = await PATCH(new Request("https://local.test/api/settings/source-import", { + method: "PATCH", + headers: { "content-length": String(64 * 1024 + 1) }, + body: "{}", + })); + expect(await readJson(response)).toEqual({ + status: 413, + body: { error: "Request body is too large.", code: "PAYLOAD_TOO_LARGE" }, + }); + }); +}); diff --git a/local/app/api/settings/source-import/route.ts b/local/app/api/settings/source-import/route.ts new file mode 100644 index 0000000..abcc298 --- /dev/null +++ b/local/app/api/settings/source-import/route.ts @@ -0,0 +1,39 @@ +import { withCurrentAdmin } from "@local/lib/api-auth"; +import { apiError, json, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http"; +import { prisma } from "@local/lib/prisma"; + +export async function GET() { + return withCurrentAdmin(async (admin) => { + const settings = await prisma.localAdmin.findUnique({ + where: { id: admin.id }, + select: { allowUnsafeSubscriptionSources: true }, + }); + + if (!settings) return apiError("Local admin not found.", "NOT_FOUND", 404); + return json(settings); + }); +} + +export async function PATCH(request: Request) { + return withCurrentAdmin(async (admin) => { + const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.small); + if (!parsedBody.ok) return jsonBodyError(parsedBody); + const body = parsedBody.value; + + const allowUnsafeSubscriptionSources = + typeof body === "object" && !Array.isArray(body) + ? (body as Record).allowUnsafeSubscriptionSources + : undefined; + if (typeof allowUnsafeSubscriptionSources !== "boolean") { + return apiError("allowUnsafeSubscriptionSources must be a boolean.", "VALIDATION_ERROR", 400); + } + + const settings = await prisma.localAdmin.update({ + where: { id: admin.id }, + data: { allowUnsafeSubscriptionSources }, + select: { allowUnsafeSubscriptionSources: true }, + }); + + return json(settings); + }); +} diff --git a/local/app/api/setup/admin/route.test.ts b/local/app/api/setup/admin/route.test.ts index 0c52218..a9a4198 100644 --- a/local/app/api/setup/admin/route.test.ts +++ b/local/app/api/setup/admin/route.test.ts @@ -3,10 +3,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ hash: vi.fn(), readJsonBody: vi.fn(), + jsonBodyError: vi.fn((result: { reason: string }, message: string) => new Response(JSON.stringify({ + error: result.reason === "too_large" ? "Request body is too large." : message, + code: result.reason === "too_large" ? "PAYLOAD_TOO_LARGE" : "BAD_REQUEST", + }), { status: result.reason === "too_large" ? 413 : 400 })), apiError: vi.fn((message: string, code: string, status: number) => new Response(JSON.stringify({ error: message, code }), { status })), getStringField: vi.fn((body: Record, key: string) => (typeof body[key] === "string" ? String(body[key]).trim() : "")), count: vi.fn(), create: vi.fn(), + queryRaw: vi.fn(), + transaction: vi.fn(), signSession: vi.fn(), sessionCookieOptions: vi.fn(), })); @@ -15,10 +21,13 @@ vi.mock("bcryptjs", () => ({ default: { hash: mocks.hash } })); vi.mock("@local/lib/http", () => ({ apiError: mocks.apiError, getStringField: mocks.getStringField, + jsonBodyError: mocks.jsonBodyError, + LOCAL_JSON_BODY_LIMITS: { small: 64 * 1024 }, readJsonBody: mocks.readJsonBody, })); vi.mock("@local/lib/prisma", () => ({ prisma: { + $transaction: mocks.transaction, localAdmin: { count: mocks.count, create: mocks.create, @@ -32,62 +41,75 @@ vi.mock("@local/lib/session", () => ({ })); import { POST } from "./route"; +import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit"; async function readJson(response: Response) { return { status: response.status, body: await response.json(), headers: response.headers }; } +function setupRequest(token = "setup-secret") { + return new Request("https://local.test/api/setup/admin", { + headers: token ? { "X-SubBoost-Setup-Token": token } : {}, + }); +} + describe("local setup admin route", () => { beforeEach(() => { vi.clearAllMocks(); + clearLocalRateLimitsForTests(); + process.env.LOCAL_SETUP_TOKEN = "setup-secret"; mocks.count.mockResolvedValue(0); mocks.hash.mockResolvedValue("hash"); mocks.create.mockResolvedValue({ id: "admin-1", username: "ry" }); + mocks.transaction.mockImplementation(async (callback) => callback({ + $queryRaw: mocks.queryRaw, + localAdmin: { count: mocks.count, create: mocks.create }, + })); mocks.signSession.mockResolvedValue("signed-session"); mocks.sessionCookieOptions.mockReturnValue({ httpOnly: true, path: "/" }); }); it("rejects invalid JSON, existing admins, and invalid credentials", async () => { - mocks.readJsonBody.mockResolvedValueOnce(null); - expect(await readJson(await POST(new Request("https://local.test/api/setup/admin")))).toMatchObject({ + mocks.readJsonBody.mockResolvedValueOnce({ ok: false, reason: "invalid_json" }); + expect(await readJson(await POST(setupRequest()))).toMatchObject({ status: 400, body: { error: "请求格式有误,请刷新页面后重试", code: "BAD_REQUEST" }, }); - mocks.readJsonBody.mockResolvedValueOnce({ username: "ry", password: "long-password", passwordConfirm: "long-password" }); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { username: "ry", password: "long-password", passwordConfirm: "long-password" } }); mocks.count.mockResolvedValueOnce(1); - expect(await readJson(await POST(new Request("https://local.test/api/setup/admin")))).toMatchObject({ + expect(await readJson(await POST(setupRequest()))).toMatchObject({ status: 409, body: { error: "已有管理员账号,请直接登录", code: "CONFLICT" }, }); - mocks.readJsonBody.mockResolvedValueOnce({ username: "ry", password: "short", passwordConfirm: "short" }); - expect(await readJson(await POST(new Request("https://local.test/api/setup/admin")))).toMatchObject({ + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { username: "ry", password: "short", passwordConfirm: "short" } }); + expect(await readJson(await POST(setupRequest()))).toMatchObject({ status: 400, body: { error: "密码至少需要 10 个字符", code: "BAD_REQUEST" }, }); - mocks.readJsonBody.mockResolvedValueOnce({ username: "", password: "long-password", passwordConfirm: "long-password" }); - expect(await readJson(await POST(new Request("https://local.test/api/setup/admin")))).toMatchObject({ + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { username: "", password: "long-password", passwordConfirm: "long-password" } }); + expect(await readJson(await POST(setupRequest()))).toMatchObject({ status: 400, body: { error: "请输入管理员账号", code: "BAD_REQUEST" }, }); - mocks.readJsonBody.mockResolvedValueOnce({ username: "ry", password: "long-password", passwordConfirm: "different-password" }); - expect(await readJson(await POST(new Request("https://local.test/api/setup/admin")))).toMatchObject({ + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { username: "ry", password: "long-password", passwordConfirm: "different-password" } }); + expect(await readJson(await POST(setupRequest()))).toMatchObject({ status: 400, body: { error: "两次输入的密码不一致,请重新确认", code: "BAD_REQUEST" }, }); }); it("creates the first local admin and sets the session cookie", async () => { - mocks.readJsonBody.mockResolvedValue({ + mocks.readJsonBody.mockResolvedValue({ ok: true, value: { username: " ry ", password: "long-password", passwordConfirm: "long-password", - }); + } }); - const result = await readJson(await POST(new Request("https://local.test/api/setup/admin"))); + const result = await readJson(await POST(setupRequest())); expect(result.status).toBe(200); expect(result.body).toEqual({ success: true, user: { id: "admin-1", username: "ry" } }); @@ -99,4 +121,39 @@ describe("local setup admin route", () => { expect(mocks.signSession).toHaveBeenCalledWith({ adminId: "admin-1", username: "ry" }); expect(result.headers.get("set-cookie")).toContain("subboost_local_session=signed-session"); }); + + it("rechecks setup state under a database lock", async () => { + mocks.readJsonBody.mockResolvedValue({ ok: true, value: { + username: "ry", + password: "long-password", + passwordConfirm: "long-password", + } }); + mocks.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1); + + const result = await readJson(await POST(setupRequest())); + + expect(result).toMatchObject({ + status: 409, + body: { error: "已有管理员账号,请直接登录", code: "CONFLICT" }, + }); + expect(mocks.queryRaw).toHaveBeenCalledWith( + expect.arrayContaining(["SELECT pg_advisory_xact_lock(", ') IS NULL AS "locked"']), + 1_397_704_283 + ); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("fails closed when the setup token is missing or misconfigured", async () => { + expect(await readJson(await POST(setupRequest("wrong")))).toMatchObject({ + status: 403, + body: { code: "FORBIDDEN" }, + }); + + delete process.env.LOCAL_SETUP_TOKEN; + expect(await readJson(await POST(setupRequest()))).toMatchObject({ + status: 503, + body: { code: "CONFIGURATION_ERROR" }, + }); + expect(mocks.readJsonBody).not.toHaveBeenCalled(); + }); }); diff --git a/local/app/api/setup/admin/route.ts b/local/app/api/setup/admin/route.ts index 3b57f55..cb0a87c 100644 --- a/local/app/api/setup/admin/route.ts +++ b/local/app/api/setup/admin/route.ts @@ -1,13 +1,35 @@ import bcrypt from "bcryptjs"; import { NextResponse } from "next/server"; import { getLocalAdminSetupCredentialError, LOCAL_ADMIN_CREDENTIAL_MESSAGES } from "@local/lib/admin-credentials"; -import { apiError, getStringField, readJsonBody } from "@local/lib/http"; +import { apiError, getStringField, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http"; import { prisma } from "@local/lib/prisma"; import { sessionCookieOptions, signSession, SESSION_COOKIE } from "@local/lib/session"; +import { consumeLocalRateLimit, getTrustedClientRateLimitKey, localRateLimitResponse } from "@local/lib/rate-limit"; +import { validateLocalSetupToken } from "@local/lib/setup-token"; export async function POST(request: Request) { - const body = await readJsonBody(request); - if (!body) return apiError(LOCAL_ADMIN_CREDENTIAL_MESSAGES.invalidJson, "BAD_REQUEST", 400); + const clientKey = getTrustedClientRateLimitKey(request); + if (clientKey) { + const setupLimit = consumeLocalRateLimit("admin-setup-client", clientKey, { + limit: 5, + windowMs: 15 * 60 * 1000, + }); + if (!setupLimit.allowed) { + return localRateLimitResponse("Too many setup attempts. Try again later.", setupLimit.retryAfterSeconds); + } + } + + const setupToken = validateLocalSetupToken(request); + if (setupToken === "missing_config") { + return apiError("LOCAL_SETUP_TOKEN is not configured.", "CONFIGURATION_ERROR", 503); + } + if (setupToken === "invalid") { + return apiError("Invalid setup token.", "FORBIDDEN", 403); + } + + const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.small); + if (!parsedBody.ok) return jsonBodyError(parsedBody, LOCAL_ADMIN_CREDENTIAL_MESSAGES.invalidJson); + const body = parsedBody.value; const existingCount = await prisma.localAdmin.count(); if (existingCount > 0) { @@ -23,10 +45,17 @@ export async function POST(request: Request) { } const passwordHash = await bcrypt.hash(password, 12); - const admin = await prisma.localAdmin.create({ - data: { username, passwordHash, lastLoginAt: new Date() }, - select: { id: true, username: true }, + const admin = await prisma.$transaction(async (transaction) => { + await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${1_397_704_283}) IS NULL AS "locked"`; + if (await transaction.localAdmin.count()) return null; + return transaction.localAdmin.create({ + data: { username, passwordHash, lastLoginAt: new Date() }, + select: { id: true, username: true }, + }); }); + if (!admin) { + return apiError(LOCAL_ADMIN_CREDENTIAL_MESSAGES.adminExists, "CONFLICT", 409); + } const response = NextResponse.json({ success: true, diff --git a/local/app/api/source-import/route.ts b/local/app/api/source-import/route.ts index 0b62232..38bca35 100644 --- a/local/app/api/source-import/route.ts +++ b/local/app/api/source-import/route.ts @@ -1,5 +1,5 @@ import { withCurrentAdmin } from "@local/lib/api-auth"; -import { apiError, json, readJsonBody } from "@local/lib/http"; +import { apiError, json, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http"; import { importSourceUrlDirect } from "@local/lib/source-import"; import { buildSourceImportParseResult } from "@subboost/server-core/subscription"; @@ -11,8 +11,12 @@ function getStringField(body: unknown, key: string): string { export async function POST(request: Request) { return withCurrentAdmin(async () => { - const body = await readJsonBody(request); - if (!body) return apiError("Invalid JSON body.", "BAD_REQUEST", 400); + const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.small); + if (!parsedBody.ok) return jsonBodyError(parsedBody); + const body = parsedBody.value; + if (!body || typeof body !== "object" || Array.isArray(body)) { + return apiError("Invalid JSON body.", "BAD_REQUEST", 400); + } const result = await importSourceUrlDirect({ url: getStringField(body, "url"), diff --git a/local/app/api/subscriptions/[id]/config.yaml/route.test.ts b/local/app/api/subscriptions/[id]/config.yaml/route.test.ts new file mode 100644 index 0000000..7506310 --- /dev/null +++ b/local/app/api/subscriptions/[id]/config.yaml/route.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + consumeLocalRateLimit: vi.fn(), + generateSubscriptionYaml: vi.fn(), + getTrustedClientRateLimitKey: vi.fn(), + hashLocalRateLimitKey: vi.fn(() => "token-hash"), + localRateLimitResponse: vi.fn( + () => new Response(JSON.stringify({ error: "limited", code: "RATE_LIMITED" }), { status: 429 }) + ), +})); + +vi.mock("@local/lib/rate-limit", () => ({ + consumeLocalRateLimit: mocks.consumeLocalRateLimit, + getTrustedClientRateLimitKey: mocks.getTrustedClientRateLimitKey, + hashLocalRateLimitKey: mocks.hashLocalRateLimitKey, + localRateLimitResponse: mocks.localRateLimitResponse, +})); +vi.mock("@local/lib/subscription-service", () => ({ + generateSubscriptionYaml: mocks.generateSubscriptionYaml, +})); + +import { GET } from "./route"; + +describe("local subscription YAML route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.consumeLocalRateLimit.mockReturnValue({ allowed: true, retryAfterSeconds: 0 }); + mocks.getTrustedClientRateLimitKey.mockReturnValue("client-hash"); + mocks.generateSubscriptionYaml.mockResolvedValue({ + yaml: "mixed-port: 7890\n", + name: "Test", + subscriptionInfo: {}, + cacheExpirySeconds: 3600, + autoUpdateIntervalSeconds: null, + isAdmin: true, + }); + }); + + it("applies available client and per-token limits before generating YAML", async () => { + const response = await GET(new Request("https://local.test/config.yaml"), { + params: Promise.resolve({ id: "secret-token" }), + }); + + expect(response.status).toBe(200); + expect(mocks.hashLocalRateLimitKey).toHaveBeenCalledWith("secret-token"); + expect(mocks.consumeLocalRateLimit).toHaveBeenNthCalledWith( + 1, + "subscription-yaml-client", + "client-hash", + { limit: 600, windowMs: 60_000 } + ); + expect(mocks.consumeLocalRateLimit).toHaveBeenNthCalledWith( + 2, + "subscription-yaml-token", + "token-hash", + { limit: 120, windowMs: 60_000 } + ); + expect(mocks.generateSubscriptionYaml).toHaveBeenCalledWith("secret-token"); + }); + + it("returns 429 before touching subscription data", async () => { + mocks.consumeLocalRateLimit.mockReturnValueOnce({ allowed: false, retryAfterSeconds: 17 }); + + const response = await GET(new Request("https://local.test/config.yaml"), { + params: Promise.resolve({ id: "secret-token" }), + }); + + expect(response.status).toBe(429); + expect(mocks.localRateLimitResponse).toHaveBeenCalledWith( + "Too many subscription requests. Try again later.", + 17 + ); + expect(mocks.generateSubscriptionYaml).not.toHaveBeenCalled(); + }); + + it("skips the client bucket when no trustworthy client key is available", async () => { + mocks.getTrustedClientRateLimitKey.mockReturnValueOnce(null); + + const response = await GET(new Request("https://local.test/config.yaml"), { + params: Promise.resolve({ id: "secret-token" }), + }); + + expect(response.status).toBe(200); + expect(mocks.consumeLocalRateLimit).toHaveBeenCalledTimes(1); + expect(mocks.consumeLocalRateLimit).toHaveBeenCalledWith( + "subscription-yaml-token", + "token-hash", + { limit: 120, windowMs: 60_000 } + ); + }); +}); diff --git a/local/app/api/subscriptions/[id]/config.yaml/route.ts b/local/app/api/subscriptions/[id]/config.yaml/route.ts index 38b8257..a5cf32e 100644 --- a/local/app/api/subscriptions/[id]/config.yaml/route.ts +++ b/local/app/api/subscriptions/[id]/config.yaml/route.ts @@ -1,13 +1,36 @@ import { apiError } from "@local/lib/http"; import { generateSubscriptionYaml } from "@local/lib/subscription-service"; import { buildSubscriptionResponseHeaders } from "@subboost/server-core/subscription"; +import { + consumeLocalRateLimit, + getTrustedClientRateLimitKey, + hashLocalRateLimitKey, + localRateLimitResponse, +} from "@local/lib/rate-limit"; type RouteContext = { params: Promise<{ id: string }>; }; -export async function GET(_request: Request, { params }: RouteContext) { +export async function GET(request: Request, { params }: RouteContext) { const { id: token } = await params; + const clientKey = getTrustedClientRateLimitKey(request); + if (clientKey) { + const clientLimit = consumeLocalRateLimit("subscription-yaml-client", clientKey, { + limit: 600, + windowMs: 60_000, + }); + if (!clientLimit.allowed) { + return localRateLimitResponse("Too many subscription requests. Try again later.", clientLimit.retryAfterSeconds); + } + } + const tokenLimit = consumeLocalRateLimit("subscription-yaml-token", hashLocalRateLimitKey(token), { + limit: 120, + windowMs: 60_000, + }); + if (!tokenLimit.allowed) { + return localRateLimitResponse("Too many subscription requests. Try again later.", tokenLimit.retryAfterSeconds); + } const result = await generateSubscriptionYaml(token); if (!result) return apiError("Subscription YAML not found.", "NOT_FOUND", 404); return new Response(result.yaml, { diff --git a/local/app/api/templates/route.ts b/local/app/api/templates/route.ts index 8df3943..a755a53 100644 --- a/local/app/api/templates/route.ts +++ b/local/app/api/templates/route.ts @@ -1,5 +1,5 @@ import { getOptionalCurrentAdmin, localAdminRequiredResponse, withCurrentAdmin } from "@local/lib/api-auth"; -import { apiError, json, readJsonBody } from "@local/lib/http"; +import { apiError, json, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http"; import { createTemplate, deleteTemplate, @@ -51,11 +51,14 @@ export async function GET(request: Request) { export async function POST(request: Request) { return withCurrentAdmin(async (admin) => { - const body = await readJsonBody(request); - if (!body) return apiError("Invalid JSON body.", "BAD_REQUEST", 400); + const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.template); + if (!parsedBody.ok) return jsonBodyError(parsedBody); + if (!parsedBody.value || typeof parsedBody.value !== "object" || Array.isArray(parsedBody.value)) { + return apiError("Invalid JSON body.", "BAD_REQUEST", 400); + } try { - const template = await createTemplate(admin.id, body); + const template = await createTemplate(admin.id, parsedBody.value); return json({ template }, 201); } catch (error) { return apiError(error instanceof Error ? error.message : "Unable to create template.", "BAD_REQUEST", 400); diff --git a/local/app/dashboard/settings/page.tsx b/local/app/dashboard/settings/page.tsx index fde0882..439e987 100644 --- a/local/app/dashboard/settings/page.tsx +++ b/local/app/dashboard/settings/page.tsx @@ -1,30 +1,93 @@ "use client"; import * as React from "react"; -import { LogOut, ServerCog, ShieldCheck } from "lucide-react"; +import { LogOut, Network, ServerCog, ShieldCheck } from "lucide-react"; import { Button } from "@subboost/ui/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@subboost/ui/components/ui/card"; +import { SwitchField } from "@subboost/ui/components/ui/switch-field"; import { useUserStore } from "@subboost/ui/store/user-store"; export default function SettingsPage() { const { user, fetchUser, logout } = useUserStore(); + const [allowUnsafeSubscriptionSources, setAllowUnsafeSubscriptionSources] = React.useState(false); + const [sourceImportLoading, setSourceImportLoading] = React.useState(true); + const [sourceImportSaving, setSourceImportSaving] = React.useState(false); + const [sourceImportError, setSourceImportError] = React.useState(null); React.useEffect(() => { void fetchUser(); }, [fetchUser]); + React.useEffect(() => { + let cancelled = false; + if (!user) { + setSourceImportLoading(false); + return () => { + cancelled = true; + }; + } + + setSourceImportLoading(true); + setSourceImportError(null); + void fetch("/api/settings/source-import", { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) throw new Error("Unable to load source import settings."); + const body = (await response.json()) as { allowUnsafeSubscriptionSources?: unknown }; + if (typeof body.allowUnsafeSubscriptionSources !== "boolean") { + throw new Error("Invalid source import settings response."); + } + if (!cancelled) setAllowUnsafeSubscriptionSources(body.allowUnsafeSubscriptionSources); + }) + .catch(() => { + if (!cancelled) setSourceImportError("加载失败,请刷新重试"); + }) + .finally(() => { + if (!cancelled) setSourceImportLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [user]); + const handleLogout = async () => { await logout(); window.location.href = "/login"; }; + const handleUnsafeSourceToggle = async (checked: boolean) => { + const previousValue = allowUnsafeSubscriptionSources; + setAllowUnsafeSubscriptionSources(checked); + setSourceImportSaving(true); + setSourceImportError(null); + + try { + const response = await fetch("/api/settings/source-import", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowUnsafeSubscriptionSources: checked }), + }); + if (!response.ok) throw new Error("Unable to save source import settings."); + const body = (await response.json()) as { allowUnsafeSubscriptionSources?: unknown }; + if (typeof body.allowUnsafeSubscriptionSources !== "boolean") { + throw new Error("Invalid source import settings response."); + } + setAllowUnsafeSubscriptionSources(body.allowUnsafeSubscriptionSources); + } catch { + setAllowUnsafeSubscriptionSources(previousValue); + setSourceImportError("保存失败,请重试"); + } finally { + setSourceImportSaving(false); + } + }; + return (

账户设置

-

本地管理员、订阅容量和运行端点

+

本地管理员、订阅源安全和运行端点

@@ -52,6 +115,25 @@ export default function SettingsPage() { + + +
+ +
+ 订阅源安全 +
+ + void handleUnsafeSourceToggle(checked)} + /> + {sourceImportError &&

{sourceImportError}

} +
+
+
diff --git a/local/app/local-pages.test.ts b/local/app/local-pages.test.ts index 2201aea..fb686f0 100644 --- a/local/app/local-pages.test.ts +++ b/local/app/local-pages.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ homeAdapter: null as any, readJsonResponse: vi.fn(), readSourceImportResponse: vi.fn(), + switches: [] as any[], templateAdapter: null as any, userState: { fetchUser: vi.fn(), @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("lucide-react", () => ({ LogOut: () => React.createElement("span", null, "LogOut"), + Network: () => React.createElement("span", null, "Network"), ServerCog: () => React.createElement("span", null, "ServerCog"), ShieldCheck: () => React.createElement("span", null, "ShieldCheck"), })); @@ -36,6 +38,13 @@ vi.mock("@subboost/ui/components/ui/card", () => ({ CardTitle: (props: any) => React.createElement("h2", props, props.children), })); +vi.mock("@subboost/ui/components/ui/switch", () => ({ + Switch: (props: any) => { + mocks.switches.push(props); + return React.createElement("button", { disabled: props.disabled, role: "switch" }); + }, +})); + vi.mock("@subboost/ui/dashboard/subscription-dashboard-surface", () => ({ SubscriptionDashboardSurface: (props: any) => { mocks.dashboardAdapter = props.adapter; @@ -85,6 +94,7 @@ describe("local app pages and adapters", () => { mocks.homeAdapter = null; mocks.templateAdapter = null; mocks.buttons = []; + mocks.switches = []; mocks.userState = { fetchUser: vi.fn(), logout: vi.fn(), @@ -217,13 +227,17 @@ describe("local app pages and adapters", () => { it("renders local settings for anonymous and authenticated states", async () => { let html = renderToStaticMarkup(React.createElement(SettingsPage)); expect(html).toContain("未登录"); + expect(html).toContain("允许本机和局域网订阅"); + expect(html).toContain("本机、局域网及其他保留地址都可作为订阅源"); expect(html).toContain("/api/health/live"); expect(mocks.buttons.find((button: any) => button.variant === "destructive")).toMatchObject({ disabled: true, }); + expect(mocks.switches[0]).toMatchObject({ checked: false, disabled: true }); vi.stubGlobal("window", { location: { href: "" } }); mocks.buttons = []; + mocks.switches = []; mocks.userState = { fetchUser: vi.fn(), logout: vi.fn(), @@ -234,6 +248,7 @@ describe("local app pages and adapters", () => { expect(html).toContain("2 / 9999"); const logoutButton = mocks.buttons.find((button: any) => button.variant === "destructive"); expect(logoutButton).toMatchObject({ disabled: false }); + expect(mocks.switches[0]).toMatchObject({ checked: false, disabled: true }); logoutButton.onClick(); await Promise.resolve(); await Promise.resolve(); diff --git a/local/docker-compose.image.yml b/local/docker-compose.image.yml index d46684a..f582ac5 100644 --- a/local/docker-compose.image.yml +++ b/local/docker-compose.image.yml @@ -27,6 +27,8 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET} CRON_SECRET: ${CRON_SECRET:?set CRON_SECRET} + LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:?set LOCAL_SETUP_TOKEN} + TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false} APP_URL: ${APP_URL:-http://localhost:3000} cron: @@ -41,7 +43,7 @@ services: [ "sh", "-c", - "i=0; while true; do if [ $$i -le 0 ]; then echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-rule-index\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-rule-index || true; i=10; fi; echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-subscriptions\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-subscriptions || true; i=$$((i - 1)); sleep 360; done" + "i=0; while true; do failed=0; if [ $$i -le 0 ]; then echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-rule-index\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-rule-index || failed=1; i=10; fi; echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-subscriptions\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-subscriptions || failed=1; i=$$((i - 1)); if [ $$failed -ne 0 ]; then echo \"[local-cron] one or more endpoints failed; restarting after delay\" >&2; sleep 30; exit 1; fi; sleep 360; done" ] volumes: diff --git a/local/docker-compose.yml b/local/docker-compose.yml index 656e220..01584a0 100644 --- a/local/docker-compose.yml +++ b/local/docker-compose.yml @@ -34,6 +34,8 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET} CRON_SECRET: ${CRON_SECRET:?set CRON_SECRET} + LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:?set LOCAL_SETUP_TOKEN} + TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false} APP_URL: ${APP_URL:-http://localhost:3000} APP_RELEASE_VERSION: ${APP_RELEASE_VERSION:-} APP_BUILD_SHA: ${APP_BUILD_SHA:-} @@ -52,7 +54,7 @@ services: [ "sh", "-c", - "i=0; while true; do if [ $$i -le 0 ]; then echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-rule-index\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-rule-index || true; i=10; fi; echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-subscriptions\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-subscriptions || true; i=$$((i - 1)); sleep 360; done" + "i=0; while true; do failed=0; if [ $$i -le 0 ]; then echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-rule-index\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-rule-index || failed=1; i=10; fi; echo \"[local-cron] $(date -Iseconds) POST /api/cron/update-subscriptions\"; curl -fsS -X POST -H \"Authorization: Bearer $${CRON_SECRET}\" http://app:3000/api/cron/update-subscriptions || failed=1; i=$$((i - 1)); if [ $$failed -ne 0 ]; then echo \"[local-cron] one or more endpoints failed; restarting after delay\" >&2; sleep 30; exit 1; fi; sleep 360; done" ] volumes: diff --git a/local/local.env.example b/local/local.env.example index ac3b24f..77ef2f9 100644 --- a/local/local.env.example +++ b/local/local.env.example @@ -5,5 +5,10 @@ DATABASE_URL= ENCRYPTION_KEY= JWT_SECRET= CRON_SECRET= +LOCAL_SETUP_TOKEN= +# 仅开发/测试可显式开启;生产环境即使设置也不会绕过 CRON_SECRET。 +ALLOW_UNAUTHENTICATED_CRON=false +# 只有反向代理会清洗并重写 X-Forwarded-For / X-Real-IP 时才设为 true。 +TRUST_PROXY_HEADERS=false APP_URL=http://localhost:3000 SUBBOOST_PORT=3000 diff --git a/local/package.json b/local/package.json index d294337..5b6ba74 100644 --- a/local/package.json +++ b/local/package.json @@ -22,9 +22,8 @@ "check": "npm run lint && npm run typecheck && npm run build" }, "dependencies": { - "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", + "@prisma/adapter-pg": "^7.9.0", + "@prisma/client": "^7.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -32,7 +31,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -46,10 +44,11 @@ "clsx": "^2.1.0", "dotenv": "^17.4.2", "jose": "^6.2.3", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.0", "lucide-react": "^1.17.0", - "next": "^16.2.7", + "next": "^16.2.12", "peggy": "^5.1.0", + "prisma": "^7.9.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", @@ -63,15 +62,9 @@ "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", - "eslint-config-next": "^16.2.7", - "postcss": "8.5.15", - "prisma": "^7.8.0", + "eslint-config-next": "^16.2.12", + "postcss": "8.5.23", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" - }, - "overrides": { - "@hono/node-server": "1.19.13", - "hono": "4.12.25", - "postcss": "8.5.15" } } diff --git a/local/prisma/migrations/20260714000000_allow_unsafe_subscription_sources/migration.sql b/local/prisma/migrations/20260714000000_allow_unsafe_subscription_sources/migration.sql new file mode 100644 index 0000000..e3e487a --- /dev/null +++ b/local/prisma/migrations/20260714000000_allow_unsafe_subscription_sources/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "LocalAdmin" +ADD COLUMN "allowUnsafeSubscriptionSources" BOOLEAN NOT NULL DEFAULT false; diff --git a/local/prisma/migrations/20260716091000_job_lease_lock/migration.sql b/local/prisma/migrations/20260716091000_job_lease_lock/migration.sql new file mode 100644 index 0000000..f40f6c8 --- /dev/null +++ b/local/prisma/migrations/20260716091000_job_lease_lock/migration.sql @@ -0,0 +1,10 @@ +CREATE TABLE "JobLeaseLock" ( + "name" TEXT NOT NULL, + "ownerToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "JobLeaseLock_pkey" PRIMARY KEY ("name") +); + +CREATE INDEX "JobLeaseLock_expiresAt_idx" ON "JobLeaseLock"("expiresAt"); diff --git a/local/prisma/schema.prisma b/local/prisma/schema.prisma index fadee48..d21427c 100644 --- a/local/prisma/schema.prisma +++ b/local/prisma/schema.prisma @@ -14,6 +14,7 @@ model LocalAdmin { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastLoginAt DateTime? + allowUnsafeSubscriptionSources Boolean @default(false) subscriptions Subscription[] templates LocalTemplate[] @@ -38,7 +39,7 @@ model Subscription { id String @id @default(cuid()) ownerId String name String - token String @unique @default(cuid()) + token String @unique isPrimary Boolean @default(false) encryptedUrls String @db.Text encryptedNodes String @db.Text @@ -72,3 +73,12 @@ model SubscriptionAutoUpdateState { subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) } + +model JobLeaseLock { + name String @id + ownerToken String + expiresAt DateTime + updatedAt DateTime @updatedAt + + @@index([expiresAt]) +} diff --git a/local/scripts/install.sh b/local/scripts/install.sh index c4c58a6..53f640a 100644 --- a/local/scripts/install.sh +++ b/local/scripts/install.sh @@ -57,6 +57,13 @@ run_root() { sudo_do "$@" } +prepare_private_directory() { + local directory="$1" + run_root mkdir -p "$directory" + run_root chmod 700 "$directory" + if ! is_root; then run_root chown "$(id -u):$(id -g)" "$directory"; fi +} + install_secret_file() { local source="$1" local destination="$2" @@ -120,15 +127,6 @@ download_to_temp() { esac } -install_file_from_url() { - local url="$1" - local destination="$2" - local mode="$3" - local tmp="$TMP_DIR/download" - download_to_temp "$url" "$tmp" - run_root install -m "$mode" "$tmp" "$destination" -} - json_get() { local key="$1" local file="$2" @@ -416,7 +414,11 @@ docker_runner() { docker_cmd() { if [ "$DOCKER_RUNNER" = "sudo docker" ]; then - sudo docker "$@" + if [ -n "${DOCKER_CONFIG:-}" ]; then + sudo env "DOCKER_CONFIG=$DOCKER_CONFIG" docker "$@" + else + sudo docker "$@" + fi else docker "$@" fi @@ -445,7 +447,7 @@ docker_login_if_needed() { } compose() { - (cd "$SUBBOOST_HOME" && docker_cmd compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@") + (cd "$SUBBOOST_HOME" && docker_cmd compose --project-directory "$SUBBOOST_HOME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@") } subboost_app_container_id() { @@ -507,20 +509,6 @@ prompt_for_port() { done } -select_existing_or_random_port() { - local current_port selected_port - current_port="$(port_number "${1:-}")" - if port_can_be_used "$current_port"; then - printf '%s\n' "$current_port" - return 0 - fi - selected_port="$(random_free_port)" - if [ -n "$current_port" ]; then - warn "已有配置端口 $current_port 被其它服务占用,已自动改用随机空闲端口 $selected_port。" - fi - printf '%s\n' "$selected_port" -} - wait_for_health() { local port="$1" local base="http://127.0.0.1:$(port_number "$port")" @@ -541,9 +529,42 @@ wait_for_health() { return 1 } +cleanup_failed_install() { + local volumes_before="$1" + local project_name + project_name="$(basename "$SUBBOOST_HOME")" + compose down --remove-orphans >/dev/null 2>&1 || true + while IFS= read -r volume; do + [ -n "$volume" ] || continue + if ! grep -Fxq "$volume" "$volumes_before"; then + docker_cmd volume rm "$volume" >/dev/null 2>&1 || true + fi + done < <(docker_cmd volume ls -q --filter "label=com.docker.compose.project=$project_name" 2>/dev/null || true) +} + +commit_candidate_install() { + local candidate_env="$1" candidate_compose="$2" candidate_manager="$3" + local live_env="$4" live_compose="$5" + local staged_env="${live_env}.candidate.$$" + local staged_compose="${live_compose}.candidate.$$" + local staged_manager="${SUBBOOST_BIN}.candidate.$$" + run_root install -m 600 "$candidate_env" "$staged_env" || return 1 + if ! is_root; then run_root chown "$(id -u):$(id -g)" "$staged_env" || return 1; fi + run_root install -m 644 "$candidate_compose" "$staged_compose" || return 1 + run_root install -m 755 "$candidate_manager" "$staged_manager" || return 1 + run_root mv -f "$staged_env" "$live_env" || return 1 + run_root mv -f "$staged_compose" "$live_compose" || return 1 + run_root mv -f "$staged_manager" "$SUBBOOST_BIN" || return 1 +} + main() { require_linux require_curl + local live_env="$ENV_FILE" + local live_compose="$COMPOSE_FILE" + if [ -e "$live_env" ] || [ -e "$live_compose" ]; then + die "Existing installation metadata was found. Use 'subboost update' or inspect the incomplete installation before retrying." + fi fetch_release_manifest local manifest_image manifest_compose manifest_manager manifest_version @@ -556,6 +577,7 @@ main() { image="${SUBBOOST_IMAGE:-${manifest_image:-$DEFAULT_IMAGE}}" compose_url="${SUBBOOST_COMPOSE_URL:-$(resolve_url "$SUBBOOST_RELEASE_URL" "${manifest_compose:-$DEFAULT_COMPOSE_URL}")}" manager_url="${SUBBOOST_MANAGER_URL:-$(resolve_url "$SUBBOOST_RELEASE_URL" "${manifest_manager:-$DEFAULT_MANAGER_URL}")}" + [ -n "$image" ] && [ -n "$compose_url" ] && [ -n "$manager_url" ] || die "Release metadata is missing image, composeUrl, or managerUrl." say "Installing SubBoost." say "Install directory: $SUBBOOST_HOME" @@ -571,12 +593,19 @@ main() { ensure_docker docker_login_if_needed - run_root mkdir -p "$SUBBOOST_HOME" "$SUBBOOST_HOME/backups" "$(dirname "$SUBBOOST_BIN")" - install_file_from_url "$compose_url" "$COMPOSE_FILE" 644 - install_file_from_url "$manager_url" "$SUBBOOST_BIN" 755 - - local existing_env="0" - if [ -f "$ENV_FILE" ]; then existing_env="1"; fi + prepare_private_directory "$SUBBOOST_HOME" + prepare_private_directory "$SUBBOOST_HOME/backups" + run_root mkdir -p "$(dirname "$SUBBOOST_BIN")" + ENV_FILE="$TMP_DIR/candidate.env" + COMPOSE_FILE="$TMP_DIR/candidate-compose.yml" + local candidate_manager="$TMP_DIR/candidate-manager" + local volumes_before="$TMP_DIR/volumes.before" + umask 077 + : > "$ENV_FILE" + download_to_temp "$compose_url" "$COMPOSE_FILE" + download_to_temp "$manager_url" "$candidate_manager" + [ -s "$COMPOSE_FILE" ] || die "Candidate Compose file is empty." + [ -s "$candidate_manager" ] && bash -n "$candidate_manager" || die "Candidate manager is invalid." set_env_value SUBBOOST_IMAGE "$image" set_env_value SUBBOOST_RELEASE_URL "$SUBBOOST_UPDATE_RELEASE_URL" @@ -588,6 +617,7 @@ main() { ensure_env_value ENCRYPTION_KEY "$(random_hex 32)" ensure_env_value JWT_SECRET "$(random_hex 32)" ensure_env_value CRON_SECRET "$(random_hex 32)" + ensure_env_value LOCAL_SETUP_TOKEN "$(random_hex 32)" local db_name db_user db_pass database_url current_url current_port default_host default_url input_url selected_port final_url recommended_port db_name="$(env_value POSTGRES_DB)" @@ -599,37 +629,46 @@ main() { current_port="${SUBBOOST_PORT:-$(env_value SUBBOOST_PORT || true)}" current_url="${APP_URL:-$(env_value APP_URL || true)}" - if [ "$existing_env" = "0" ] || [ -z "$current_url" ]; then - default_host="$(detect_public_host)" - if [ -n "$current_url" ]; then - default_url="$(url_without_port "$current_url")" - else - default_url="http://$default_host" - fi - recommended_port="$(recommended_port_from "$current_port")" - input_url="$(prompt "请输入 SubBoost 访问地址,直接回车会自动填入服务器 ip [$default_url]: " "$default_url")" - selected_port="$(prompt_for_port "$recommended_port")" - final_url="$(normalize_app_url "$(url_without_port "$input_url")" "$selected_port")" - set_env_value SUBBOOST_PORT "$selected_port" - set_env_value APP_URL "$final_url" + default_host="$(detect_public_host)" + if [ -n "$current_url" ]; then + default_url="$(url_without_port "$current_url")" else - selected_port="$(select_existing_or_random_port "$current_port")" - final_url="$(normalize_app_url "$(url_without_port "$current_url")" "$selected_port")" - set_env_value SUBBOOST_PORT "$selected_port" - set_env_value APP_URL "$final_url" - fi - - say "Pulling SubBoost image..." + default_url="http://$default_host" + fi + recommended_port="$(recommended_port_from "$current_port")" + input_url="$(prompt "请输入 SubBoost 访问地址,直接回车会自动填入服务器 ip [$default_url]: " "$default_url")" + selected_port="$(prompt_for_port "$recommended_port")" + final_url="$(normalize_app_url "$(url_without_port "$input_url")" "$selected_port")" + set_env_value SUBBOOST_PORT "$selected_port" + set_env_value APP_URL "$final_url" + + compose config >/dev/null + local services + services="$(compose config --services)" + for service in app db cron; do + printf '%s\n' "$services" | grep -Fxq "$service" || die "Candidate Compose is missing service: $service" + done + docker_cmd volume ls -q > "$volumes_before" + say "Pulling SubBoost image before creating containers..." compose pull say "Starting SubBoost..." - compose up -d --remove-orphans - compose up -d --no-deps --force-recreate app - wait_for_health "$(env_value SUBBOOST_PORT)" + if ! compose up -d db app || ! wait_for_health "$(env_value SUBBOOST_PORT)" || ! compose up -d cron; then + cleanup_failed_install "$volumes_before" + die "New installation failed; only resources created by this run were cleaned up." + fi + if ! commit_candidate_install "$ENV_FILE" "$COMPOSE_FILE" "$candidate_manager" "$live_env" "$live_compose"; then + cleanup_failed_install "$volumes_before" + run_root rm -f "$live_env" "$live_compose" "${live_env}.candidate.$$" "${live_compose}.candidate.$$" "${SUBBOOST_BIN}.candidate.$$" + die "Failed to atomically install candidate metadata." + fi + ENV_FILE="$live_env" + COMPOSE_FILE="$live_compose" say "" say "SubBoost 已启动。" say "访问地址: $(env_value APP_URL)" - say "第一次打开网页时,请创建管理员账号。" + say "首次初始化链接: $(env_value APP_URL)/login#setup-token=$(env_value LOCAL_SETUP_TOKEN)" + say "请通过上面的链接创建第一个管理员;初始化成功后页面会清除令牌片段。" say "管理命令: subboost" say "重要提醒: 请把 $ENV_FILE 和数据库备份一起保存好。" } diff --git a/local/scripts/selfhost-lifecycle-shell.test.ts b/local/scripts/selfhost-lifecycle-shell.test.ts new file mode 100644 index 0000000..b6bb1c8 --- /dev/null +++ b/local/scripts/selfhost-lifecycle-shell.test.ts @@ -0,0 +1,151 @@ +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const publicRoot = path.resolve(__dirname, "../.."); + +function runRollbackScenario(restoreFails: boolean) { + const script = ` + set -Eeuo pipefail + home="$(mktemp -d)" + trap 'rm -rf "$home"' EXIT + mkdir -p "$home/release" "$home/bin" + cat > "$home/release/release.json" <<'JSON' +{"image":"new-image","composeUrl":"compose.yml","managerUrl":"manager"} +JSON + printf 'services:\n app: {}\n db: {}\n cron: {}\n' > "$home/release/compose.yml" + printf '#!/usr/bin/env bash\nexit 0\n' > "$home/release/manager" + cat > "$home/.env" < "$home/docker-compose.yml" + export SUBBOOST_SCRIPT_SOURCE_ONLY=1 + export SUBBOOST_HOME="$home" + export SUBBOOST_BIN="$home/bin/subboost" + export SUBBOOST_DOCTOR_HEALTH_ATTEMPTS=1 + export SUBBOOST_DOCTOR_HEALTH_INTERVAL_SECONDS=0 + source local/scripts/subboost.sh + sudo_do() { "$@"; } + read_env_file() { cat "$ENV_FILE"; } + log="$home/docker.log" + state="$home/state" + : > "$log" + printf 'old\n' > "$state" + docker() { + printf '%s\n' "$*" >> "$log" + if [ "$1" = "compose" ]; then + case "$*" in + *" config --services") printf 'app\ndb\ncron\n'; return 0 ;; + *" config") return 0 ;; + *" ps -q app") printf 'app-id\n'; return 0 ;; + *"pg_dump -Fc"*) printf 'custom-dump'; return 0 ;; + *"pg_restore --list"*) cat >/dev/null; return 0 ;; + *"pg_restore --clean"*) cat >/dev/null; [ "${restoreFails ? "1" : "0"}" = "0" ]; return $? ;; + *"candidate-compose.yml"*" up -d --no-deps app") printf 'candidate\n' > "$state"; return 0 ;; + *"old-compose.yml"*" up -d app") printf 'old\n' > "$state"; return 0 ;; + esac + return 0 + fi + if [ "$1" = "inspect" ]; then printf 'sha256:old-image\n'; return 0; fi + return 0 + } + curl() { [ "$(cat "$state")" = "old" ]; } + set +e + output="$(update_cmd 2>&1)" + status=$? + set -e + printf 'status=%s\n%s\n' "$status" "$output" + cat "$log" + [ "$status" -ne 0 ] + `; + return spawnSync("bash", ["-lc", "exec \"$BASH\" -s"], { + cwd: publicRoot, + encoding: "utf8", + input: script, + timeout: 30_000, + env: { ...process.env, LC_ALL: "C.UTF-8" }, + }); +} + +describe("self-host update rollback lifecycle", () => { + it("restores the verified dump and old image after candidate health failure", () => { + const result = runRollbackScenario(false); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Candidate update failed: candidate health check failed"); + expect(result.stdout).toContain("pg_restore --clean --if-exists --exit-on-error"); + expect(result.stdout).toContain("tag subboost-rollback:update-"); + expect(result.stdout).toContain("old-image"); + expect(result.stdout).toContain("Previous version restored successfully."); + }); + + it("keeps writers stopped and preserves the dump when database restore fails", () => { + const result = runRollbackScenario(true); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Automatic rollback stopped: database restore failed"); + expect(result.stdout).toContain("Keep app and cron stopped"); + expect(result.stdout).toMatch(/old-compose\.yml.* stop cron app/); + expect(result.stdout).not.toMatch(/old-compose\.yml.* up -d app/); + }); + + it("never deletes a volume that existed before a failed installation", () => { + const result = spawnSync("bash", ["-lc", "exec \"$BASH\" -s"], { + cwd: publicRoot, + encoding: "utf8", + input: ` + set -Eeuo pipefail + home="$(mktemp -d)" + trap 'rm -rf "$home"' EXIT + : > "$home/env" + : > "$home/compose.yml" + printf 'existing-volume\n' > "$home/volumes.before" + export SUBBOOST_SCRIPT_SOURCE_ONLY=1 SUBBOOST_HOME="$home" + source local/scripts/install.sh + ENV_FILE="$home/env" COMPOSE_FILE="$home/compose.yml" DOCKER_RUNNER=docker + docker() { + case "$*" in + "volume ls -q"*) printf 'existing-volume\nnew-volume\n' ;; + "volume rm"*) printf 'removed=%s\n' "$3" >> "$home/removed" ;; + esac + } + cleanup_failed_install "$home/volumes.before" + cat "$home/removed" + `, + timeout: 30_000, + env: { ...process.env, LC_ALL: "C.UTF-8" }, + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("removed=new-volume"); + expect(result.stdout).not.toContain("removed=existing-volume"); + }); + + it("persists a setup token and prints only the fragment bootstrap link", () => { + const installer = readFileSync(path.join(publicRoot, "local/scripts/install.sh"), "utf8"); + expect(installer).toContain('ensure_env_value LOCAL_SETUP_TOKEN "$(random_hex 32)"'); + expect(installer).toContain('prepare_private_directory "$SUBBOOST_HOME"'); + expect(installer).toContain('prepare_private_directory "$SUBBOOST_HOME/backups"'); + expect(installer).toContain("/login#setup-token=$(env_value LOCAL_SETUP_TOKEN)"); + expect(installer).not.toContain("?setup-token="); + }); + + it("propagates cron endpoint failures to the container restart policy", () => { + for (const file of ["local/docker-compose.yml", "local/docker-compose.image.yml"]) { + const compose = readFileSync(path.join(publicRoot, file), "utf8"); + expect(compose).toContain("LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:?set LOCAL_SETUP_TOKEN}"); + expect(compose).toContain("curl -fsS"); + expect(compose).toContain("failed=1"); + expect(compose).toContain("exit 1"); + expect(compose).not.toContain("|| true"); + } + }); +}); diff --git a/local/scripts/selfhost-shell.test.ts b/local/scripts/selfhost-shell.test.ts index 3895239..2596f2e 100644 --- a/local/scripts/selfhost-shell.test.ts +++ b/local/scripts/selfhost-shell.test.ts @@ -3,8 +3,12 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; const publicRoot = path.resolve(__dirname, "../.."); -const BASH_NON_INTERACTIVE_COMMAND = - "if command -v setsid >/dev/null 2>&1; then exec setsid \"$BASH\" -s; fi; exec \"$BASH\" -s"; +const BASH_NON_INTERACTIVE_COMMAND = "exec \"$BASH\" -s"; +const POSIX_BACKUP_MODE_ASSERTIONS = process.platform === "win32" + ? ": # NTFS permissions are verified by Windows ACL checks, not POSIX mode bits" + : ` + [ "$unsafe_files" = "0" ] + [ "$unsafe_dirs" = "0" ]`; function runBash(script: string) { return spawnSync("bash", ["-lc", BASH_NON_INTERACTIVE_COMMAND], { @@ -12,6 +16,7 @@ function runBash(script: string) { encoding: "utf8", input: script, timeout: 30_000, + detached: true, env: { ...process.env, LC_ALL: "C.UTF-8", @@ -20,6 +25,23 @@ function runBash(script: string) { } describe("self-host shell scripts", () => { + it("preserves an explicit Docker config when Docker requires sudo", () => { + const result = runBash(` + set -Eeuo pipefail + export SUBBOOST_SCRIPT_SOURCE_ONLY=1 + source local/scripts/install.sh + export DOCKER_CONFIG=/tmp/subboost-isolated-docker-config + DOCKER_RUNNER="sudo docker" + sudo() { printf 'sudo-call=%s\\n' "$*"; } + docker_cmd info + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "sudo-call=env DOCKER_CONFIG=/tmp/subboost-isolated-docker-config docker info", + ); + }); + it("uses prompt defaults without /dev/tty errors in non-interactive mode", () => { const result = runBash(` set -Eeuo pipefail @@ -238,13 +260,17 @@ ENV export SUBBOOST_DOCTOR_HEALTH_ATTEMPTS=3 export SUBBOOST_DOCTOR_HEALTH_INTERVAL_SECONDS=0 source local/scripts/subboost.sh + sudo_do() { "$@"; } docker() { if [ "$1" = "info" ]; then return 0; fi if [ "$1" = "compose" ]; then case "$*" in "compose version"*) return 0 ;; + *" config --services") printf 'app\\ndb\\ncron\\n'; return 0 ;; *" config") return 0 ;; *" pull") return 0 ;; + *"pg_dump -Fc"*) printf 'custom-dump'; return 0 ;; + *"pg_restore --list"*) cat >/dev/null; return 0 ;; *" up -d --remove-orphans") return 0 ;; *" up -d --no-deps --force-recreate app") return 0 ;; *" ps -q app") printf 'app-id\\n'; return 0 ;; @@ -254,6 +280,7 @@ ENV fi if [ "$1" = "inspect" ]; then case "$*" in + *"{{.Image}}"*) printf 'sha256:old-image\\n'; return 0 ;; *".State.Status"*) printf 'running\\n'; return 0 ;; *".State.Health"*) printf 'healthy\\n'; return 0 ;; esac @@ -324,15 +351,19 @@ ENV docker_log="$home/docker-log" : > "$docker_log" docker() { + printf 'command=%s\n' "$*" >> "$docker_log" if [ "$1" = "info" ]; then return 0; fi if [ "$1" = "compose" ]; then case "$*" in "compose version"*) return 0 ;; + *" config --services") printf 'app\\ndb\\ncron\\n'; return 0 ;; *" config") return 0 ;; *" pull") printf 'pull_image=%s\\n' "\${SUBBOOST_IMAGE:-}" >> "$docker_log" return 0 ;; + *"pg_dump -Fc"*) printf 'custom-dump'; return 0 ;; + *"pg_restore --list"*) cat >/dev/null; return 0 ;; *" up -d --remove-orphans") return 0 ;; *" up -d --no-deps --force-recreate app") return 0 ;; *" ps -q app") printf 'app-id\\n'; return 0 ;; @@ -342,6 +373,7 @@ ENV fi if [ "$1" = "inspect" ]; then case "$*" in + *"{{.Image}}"*) printf 'sha256:old-image\\n'; return 0 ;; *".State.Status"*) printf 'running\\n'; return 0 ;; *".State.Health"*) printf 'healthy\\n'; return 0 ;; esac @@ -361,6 +393,14 @@ ENV expect(result.stdout).toContain("SUBBOOST_IMAGE=new-image"); expect(result.stdout).toContain("SUBBOOST_COMPOSE_URL=file://"); expect(result.stdout).toContain("SUBBOOST_MANAGER_URL=file://"); + const pullIndex = result.stdout.search(/^command=compose.* pull$/m); + const pauseIndex = result.stdout.indexOf(" stop cron app"); + const dumpIndex = result.stdout.indexOf("pg_dump -Fc"); + const candidateStartIndex = result.stdout.indexOf("candidate-compose.yml", dumpIndex); + expect(pullIndex).toBeGreaterThanOrEqual(0); + expect(pauseIndex).toBeGreaterThan(pullIndex); + expect(dumpIndex).toBeGreaterThan(pauseIndex); + expect(candidateStartIndex).toBeGreaterThan(dumpIndex); }, 10_000); it("migrates old fixed official update sources to stable latest", () => { @@ -414,11 +454,14 @@ JSON if [ "$1" = "compose" ]; then case "$*" in "compose version"*) return 0 ;; + *" config --services") printf 'app\\ndb\\ncron\\n'; return 0 ;; *" config") return 0 ;; *" pull") printf 'pull_image=%s\\n' "\${SUBBOOST_IMAGE:-}" >> "$docker_log" return 0 ;; + *"pg_dump -Fc"*) printf 'custom-dump'; return 0 ;; + *"pg_restore --list"*) cat >/dev/null; return 0 ;; *" up -d --remove-orphans") return 0 ;; *" up -d --no-deps --force-recreate app") return 0 ;; *" ps -q app") printf 'app-id\\n'; return 0 ;; @@ -428,6 +471,7 @@ JSON fi if [ "$1" = "inspect" ]; then case "$*" in + *"{{.Image}}"*) printf 'sha256:old-image\\n'; return 0 ;; *".State.Status"*) printf 'running\\n'; return 0 ;; *".State.Health"*) printf 'healthy\\n'; return 0 ;; esac @@ -495,22 +539,30 @@ ENV source local/scripts/subboost.sh sudo_do() { "$@"; } load_env() { :; } - compose() { printf 'dump'; } + compose() { + case "$*" in + *"pg_dump -Fc"*) printf 'custom-dump' ;; + *"pg_restore --list"*) cat >/dev/null ;; + esac + } cat > "$ENV_FILE" <<'ENV' POSTGRES_DB=subboost POSTGRES_USER=subboost ENV for i in $(seq -w 1 12); do - : > "$BACKUP_DIR/subboost-20240101T0000\${i}Z.sql.gz" + : > "$BACKUP_DIR/subboost-20240101T0000\${i}Z.dump" : > "$BACKUP_DIR/subboost-20240101T0000\${i}Z.env" done backup_cmd >/dev/null - sql_count="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'subboost-*.sql.gz' | wc -l | tr -d '[:space:]')" + sql_count="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'subboost-*.dump' | wc -l | tr -d '[:space:]')" env_count="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'subboost-*.env' | wc -l | tr -d '[:space:]')" - printf 'sql=%s env=%s\\n' "$sql_count" "$env_count" + unsafe_files="$(find "$BACKUP_DIR" -maxdepth 1 -type f ! -perm 600 | wc -l | tr -d '[:space:]')" + unsafe_dirs="$(find "$BACKUP_DIR" -maxdepth 0 -type d ! -perm 700 | wc -l | tr -d '[:space:]')" + printf 'sql=%s env=%s unsafe_files=%s unsafe_dirs=%s\\n' "$sql_count" "$env_count" "$unsafe_files" "$unsafe_dirs" [ "$sql_count" = "10" ] [ "$env_count" = "10" ] - [ ! -e "$BACKUP_DIR/subboost-20240101T000001Z.sql.gz" ] + ${POSIX_BACKUP_MODE_ASSERTIONS} + [ ! -e "$BACKUP_DIR/subboost-20240101T000001Z.dump" ] [ ! -e "$BACKUP_DIR/subboost-20240101T000001Z.env" ] `; @@ -518,5 +570,8 @@ ENV expect(result.status).toBe(0); expect(result.stdout).toContain("sql=10 env=10"); + if (process.platform !== "win32") { + expect(result.stdout).toContain("unsafe_files=0 unsafe_dirs=0"); + } }); }); diff --git a/local/scripts/subboost.sh b/local/scripts/subboost.sh index c564fd7..b4189c3 100644 --- a/local/scripts/subboost.sh +++ b/local/scripts/subboost.sh @@ -27,6 +27,13 @@ sudo_do() { if is_root; then "$@"; else sudo "$@"; fi } +prepare_private_directory() { + local directory="$1" + sudo_do mkdir -p "$directory" + sudo_do chmod 700 "$directory" + if ! is_root; then sudo_do chown "$(id -u):$(id -g)" "$directory"; fi +} + install_secret_file() { local source="$1" local destination="$2" @@ -60,9 +67,16 @@ docker_cmd() { } compose() { - [ -f "$COMPOSE_FILE" ] || die "Missing $COMPOSE_FILE" - [ -f "$ENV_FILE" ] || die "Missing $ENV_FILE" - (cd "$SUBBOOST_HOME" && docker_cmd compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@") + compose_files "$ENV_FILE" "$COMPOSE_FILE" "$@" +} + +compose_files() { + local env_file="$1" + local compose_file="$2" + shift 2 + [ -f "$compose_file" ] || die "Missing $compose_file" + [ -f "$env_file" ] || die "Missing $env_file" + (cd "$SUBBOOST_HOME" && docker_cmd compose --project-directory "$SUBBOOST_HOME" --env-file "$env_file" -f "$compose_file" "$@") } load_env() { @@ -120,7 +134,7 @@ resolve_url() { } read_env_file() { - if is_root; then cat "$ENV_FILE"; else sudo cat "$ENV_FILE"; fi + sudo_do cat "$ENV_FILE" } write_env_value() { @@ -147,11 +161,47 @@ is_official_fixed_release_url() { esac } -migrate_update_release_url() { - local release_url="$1" - is_official_fixed_release_url "$release_url" || return 1 - say "Detected old fixed release update source; switching updates to stable latest." - write_runtime_env_value SUBBOOST_RELEASE_URL "$DEFAULT_STABLE_RELEASE_URL" +random_hex() { + local bytes="$1" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$bytes" + else + dd if=/dev/urandom bs="$bytes" count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' + fi +} + +set_file_env_value() { + local file="$1" + local key="$2" + local value="$3" + local tmp="$TMP_DIR/env.$key" + awk -F= -v key="$key" '$1 != key { print }' "$file" > "$tmp" + printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$file" +} + +atomic_install_file() { + local source="$1" + local destination="$2" + local mode="$3" + stage_install_file "$source" "$destination" "$mode" + activate_staged_file "$destination" +} + +stage_install_file() { + local source="$1" + local destination="$2" + local mode="$3" + local staged="${destination}.candidate.$$" + sudo_do install -m "$mode" "$source" "$staged" || return 1 + if [ "$mode" = "600" ] && ! is_root; then + sudo_do chown "$(id -u):$(id -g)" "$staged" || return 1 + fi +} + +activate_staged_file() { + local destination="$1" + sudo_do mv -f "${destination}.candidate.$$" "$destination" } install_file_from_url() { @@ -164,6 +214,33 @@ install_file_from_url() { sudo_do install -m "$mode" "$tmp" "$destination" } +create_verified_dump() { + local output="$1" + local partial="${output}.partial" + local -a dump_status verify_status + prepare_private_directory "$(dirname "$output")" + sudo_do install -m 600 /dev/null "$partial" + set +e + compose exec -T db pg_dump -Fc -U "${POSTGRES_USER:-subboost}" -d "${POSTGRES_DB:-subboost}" | sudo_do tee "$partial" >/dev/null + dump_status=("${PIPESTATUS[@]}") + set -e + if (( dump_status[0] != 0 || dump_status[1] != 0 )) || [ ! -s "$partial" ]; then + sudo_do rm -f -- "$partial" + say "Backup failed: pg_dump=${dump_status[0]} write=${dump_status[1]}" + return 1 + fi + set +e + sudo_do cat "$partial" | compose exec -T db pg_restore --list >/dev/null + verify_status=("${PIPESTATUS[@]}") + set -e + if (( verify_status[0] != 0 || verify_status[1] != 0 )); then + sudo_do rm -f -- "$partial" + say "Backup verification failed: read=${verify_status[0]} pg_restore=${verify_status[1]}" + return 1 + fi + sudo_do mv "$partial" "$output" +} + port_number() { local value="$1" case "$value" in @@ -304,36 +381,156 @@ update_cmd() { load_env local release_url="${SUBBOOST_RELEASE_URL:-}" local release_file="$TMP_DIR/release.json" - local image compose_url manager_url + local candidate_env="$TMP_DIR/candidate.env" + local candidate_compose="$TMP_DIR/candidate-compose.yml" + local candidate_manager="$TMP_DIR/candidate-manager" + local old_env="$TMP_DIR/old.env" + local old_compose="$TMP_DIR/old-compose.yml" + local old_manager="$TMP_DIR/old-manager" + local rollback_dump="$BACKUP_DIR/update-rollback-$(date -u +%Y%m%dT%H%M%SZ).dump" + local image="${SUBBOOST_IMAGE:-}" compose_url="" manager_url="" services="" + local app_id old_image_id rollback_tag old_image_ref update_error restore_error db_ready + local manager_present=0 old_manager_present=0 + local -a restore_status mkdir -p "$TMP_DIR" - if migrate_update_release_url "$release_url"; then + if is_official_fixed_release_url "$release_url"; then + say "Detected old fixed release update source; switching updates to stable latest." release_url="$DEFAULT_STABLE_RELEASE_URL" fi if [ -n "$release_url" ] && download_to_temp "$release_url" "$release_file" 2>/dev/null; then image="$(json_get image "$release_file" || true)" compose_url="$(resolve_url "$release_url" "$(json_get composeUrl "$release_file" || true)")" manager_url="$(resolve_url "$release_url" "$(json_get managerUrl "$release_file" || true)")" - if [ -n "$image" ]; then write_runtime_env_value SUBBOOST_IMAGE "$image"; fi - if [ -n "$compose_url" ]; then - install_file_from_url "$compose_url" "$COMPOSE_FILE" 644 - write_runtime_env_value SUBBOOST_COMPOSE_URL "$compose_url" - fi - if [ -n "$manager_url" ]; then - install_file_from_url "$manager_url" "${SUBBOOST_BIN:-/usr/local/bin/subboost}" 755 - write_runtime_env_value SUBBOOST_MANAGER_URL "$manager_url" - fi + [ -n "$image" ] && [ -n "$compose_url" ] && [ -n "$manager_url" ] || die "Release manifest is missing image, composeUrl, or managerUrl." + download_to_temp "$compose_url" "$candidate_compose" + download_to_temp "$manager_url" "$candidate_manager" + [ -s "$candidate_manager" ] && bash -n "$candidate_manager" || die "Candidate manager is invalid." else say "Release manifest unavailable; updating current image and compose only." + cp "$COMPOSE_FILE" "$candidate_compose" + if [ -f "${SUBBOOST_BIN:-/usr/local/bin/subboost}" ]; then + sudo_do cp "${SUBBOOST_BIN:-/usr/local/bin/subboost}" "$candidate_manager" + manager_present=1 + fi fi - compose pull - compose up -d --remove-orphans - compose up -d --no-deps --force-recreate app - if ! wait_for_health; then - local health_status - health_status="$(health_status_code)" - status_cmd - die "$(doctor_health_failure_message "$health_status")" + [ -n "$manager_url" ] && manager_present=1 + [ -n "$image" ] || die "SUBBOOST_IMAGE is missing." + read_env_file > "$candidate_env" + cp "$candidate_env" "$old_env" + cp "$COMPOSE_FILE" "$old_compose" + if [ -f "${SUBBOOST_BIN:-/usr/local/bin/subboost}" ]; then + sudo_do cp "${SUBBOOST_BIN:-/usr/local/bin/subboost}" "$old_manager" + old_manager_present=1 + fi + set_file_env_value "$candidate_env" SUBBOOST_IMAGE "$image" + set_file_env_value "$candidate_env" SUBBOOST_RELEASE_URL "$release_url" + [ -n "$compose_url" ] && set_file_env_value "$candidate_env" SUBBOOST_COMPOSE_URL "$compose_url" + [ -n "$manager_url" ] && set_file_env_value "$candidate_env" SUBBOOST_MANAGER_URL "$manager_url" + if ! grep -q '^LOCAL_SETUP_TOKEN=.' "$candidate_env"; then + set_file_env_value "$candidate_env" LOCAL_SETUP_TOKEN "$(random_hex 32)" + fi + compose_files "$candidate_env" "$candidate_compose" config >/dev/null + services="$(compose_files "$candidate_env" "$candidate_compose" config --services)" + for service in app db cron; do + printf '%s\n' "$services" | grep -Fxq "$service" || die "Candidate Compose is missing service: $service" + done + say "Pulling candidate image before the maintenance window..." + SUBBOOST_IMAGE="$image" compose_files "$candidate_env" "$candidate_compose" pull + + app_id="$(service_container_id app)" + [ -n "$app_id" ] || die "Cannot identify the current app container for rollback." + old_image_id="$(docker_cmd inspect -f '{{.Image}}' "$app_id" 2>/dev/null || true)" + [ -n "$old_image_id" ] || die "Cannot identify the current app image for rollback." + old_image_ref="${SUBBOOST_IMAGE:-}" + rollback_tag="subboost-rollback:update-$$" + if ! stage_install_file "$candidate_env" "$ENV_FILE" 600 \ + || ! stage_install_file "$candidate_compose" "$COMPOSE_FILE" 644 \ + || { [ "$manager_present" = "1" ] && ! stage_install_file "$candidate_manager" "${SUBBOOST_BIN:-/usr/local/bin/subboost}" 755; }; then + sudo_do rm -f "${ENV_FILE}.candidate.$$" "${COMPOSE_FILE}.candidate.$$" "${SUBBOOST_BIN:-/usr/local/bin/subboost}.candidate.$$" + die "Candidate metadata could not be staged safely." + fi + if ! docker_cmd tag "$old_image_id" "$rollback_tag"; then + sudo_do rm -f "${ENV_FILE}.candidate.$$" "${COMPOSE_FILE}.candidate.$$" "${SUBBOOST_BIN:-/usr/local/bin/subboost}.candidate.$$" + die "Could not create the rollback image tag." + fi + + say "Pausing app and cron for a stable database snapshot..." + if ! compose stop cron app; then + sudo_do rm -f "${ENV_FILE}.candidate.$$" "${COMPOSE_FILE}.candidate.$$" "${SUBBOOST_BIN:-/usr/local/bin/subboost}.candidate.$$" + docker_cmd image rm "$rollback_tag" >/dev/null 2>&1 || true + die "Update aborted because app and cron could not be paused safely." + fi + if ! create_verified_dump "$rollback_dump"; then + compose up -d app cron || true + wait_for_health || true + sudo_do rm -f "${ENV_FILE}.candidate.$$" "${COMPOSE_FILE}.candidate.$$" "${SUBBOOST_BIN:-/usr/local/bin/subboost}.candidate.$$" + docker_cmd image rm "$rollback_tag" >/dev/null 2>&1 || true + die "Update aborted because a verified rollback dump could not be created." + fi + + update_error="" + compose_files "$candidate_env" "$candidate_compose" up -d db || update_error="candidate database startup failed" + if [ -z "$update_error" ]; then + compose_files "$candidate_env" "$candidate_compose" up -d --no-deps app || update_error="candidate app startup or migration failed" + fi + if [ -z "$update_error" ] && ! wait_for_health; then + update_error="candidate health check failed" fi + if [ -z "$update_error" ]; then + activate_staged_file "$ENV_FILE" || update_error="candidate environment activation failed" + fi + if [ -z "$update_error" ]; then + activate_staged_file "$COMPOSE_FILE" || update_error="candidate Compose activation failed" + fi + if [ -z "$update_error" ] && [ "$manager_present" = "1" ]; then + activate_staged_file "${SUBBOOST_BIN:-/usr/local/bin/subboost}" || update_error="candidate manager activation failed" + fi + if [ -z "$update_error" ]; then + compose_files "$candidate_env" "$candidate_compose" up -d cron || update_error="candidate cron startup failed" + fi + + if [ -n "$update_error" ]; then + say "Candidate update failed: $update_error" + compose_files "$candidate_env" "$candidate_compose" stop cron app >/dev/null 2>&1 || true + docker_cmd tag "$rollback_tag" "$old_image_ref" || true + sudo_do rm -f "${ENV_FILE}.candidate.$$" "${COMPOSE_FILE}.candidate.$$" "${SUBBOOST_BIN:-/usr/local/bin/subboost}.candidate.$$" + restore_error="" + atomic_install_file "$old_env" "$ENV_FILE" 600 || restore_error="old environment metadata restore failed" + atomic_install_file "$old_compose" "$COMPOSE_FILE" 644 || restore_error="old Compose metadata restore failed" + if [ "$old_manager_present" = "1" ]; then + atomic_install_file "$old_manager" "${SUBBOOST_BIN:-/usr/local/bin/subboost}" 755 || restore_error="old manager metadata restore failed" + fi + db_ready=1 + compose_files "$old_env" "$old_compose" up -d db || { restore_error="old database container did not start"; db_ready=0; } + if [ "$db_ready" = "1" ]; then + set +e + sudo_do cat "$rollback_dump" | compose_files "$old_env" "$old_compose" exec -T db pg_restore --clean --if-exists --exit-on-error --no-owner --no-privileges -U "${POSTGRES_USER:-subboost}" -d "${POSTGRES_DB:-subboost}" + restore_status=("${PIPESTATUS[@]}") + set -e + if (( restore_status[0] != 0 || restore_status[1] != 0 )); then restore_error="database restore failed"; fi + fi + if [ -n "$restore_error" ]; then + compose_files "$old_env" "$old_compose" stop cron app >/dev/null 2>&1 || true + say "Automatic rollback stopped: $restore_error" + say "Rollback dump preserved at: $rollback_dump" + say "Keep app and cron stopped. Restore manually with pg_restore before restarting them." + return 1 + fi + compose_files "$old_env" "$old_compose" up -d app + if ! wait_for_health; then + compose_files "$old_env" "$old_compose" stop cron app >/dev/null 2>&1 || true + say "Database and old image were restored, but the old app did not become healthy." + say "Rollback dump preserved at: $rollback_dump" + return 1 + fi + compose_files "$old_env" "$old_compose" up -d cron + docker_cmd image rm "$rollback_tag" >/dev/null 2>&1 || true + say "Previous version restored successfully." + return 1 + fi + + docker_cmd image rm "$rollback_tag" >/dev/null 2>&1 || true + load_env status_cmd } @@ -343,36 +540,28 @@ logs_cmd() { backup_cmd() { load_env - sudo_do mkdir -p "$BACKUP_DIR" - local stamp db_tmp db_out env_out + prepare_private_directory "$BACKUP_DIR" + local stamp db_out env_out local -a sql_backups env_backups - local -a backup_status local i backup_retention_count backup_retention_count="${SUBBOOST_BACKUP_RETENTION_COUNT:-$DEFAULT_BACKUP_RETENTION_COUNT}" if ! [[ "$backup_retention_count" =~ ^[0-9]+$ ]] || (( backup_retention_count < 1 )); then die "SUBBOOST_BACKUP_RETENTION_COUNT must be a positive integer" fi stamp="$(date -u +%Y%m%dT%H%M%SZ)" - db_tmp="$BACKUP_DIR/subboost-$stamp.sql.gz.partial" - db_out="$BACKUP_DIR/subboost-$stamp.sql.gz" + db_out="$BACKUP_DIR/subboost-$stamp.dump" env_out="$BACKUP_DIR/subboost-$stamp.env" - set +e - compose exec -T db pg_dump -U "${POSTGRES_USER:-subboost}" -d "${POSTGRES_DB:-subboost}" | gzip -c | sudo_do tee "$db_tmp" >/dev/null - backup_status=("${PIPESTATUS[@]}") - set -e - if (( backup_status[0] != 0 || backup_status[1] != 0 || backup_status[2] != 0 )); then - sudo_do rm -f -- "$db_tmp" - say "Backup failed: pg_dump=${backup_status[0]} gzip=${backup_status[1]} write=${backup_status[2]}" - return 1 - fi - sudo_do mv "$db_tmp" "$db_out" + create_verified_dump "$db_out" sudo_do install -m 600 "$ENV_FILE" "$env_out" shopt -s nullglob - sql_backups=("$BACKUP_DIR"/subboost-*.sql.gz) + sql_backups=("$BACKUP_DIR"/subboost-*.dump) env_backups=("$BACKUP_DIR"/subboost-*.env) shopt -u nullglob + if ((${#sql_backups[@]} > 0)); then sudo_do chmod 600 "${sql_backups[@]}"; fi + if ((${#env_backups[@]} > 0)); then sudo_do chmod 600 "${env_backups[@]}"; fi + for ((i = 0; i < ${#sql_backups[@]} - backup_retention_count; i++)); do sudo_do rm -f -- "${sql_backups[$i]}" done diff --git a/local/src/components/local-login.test.ts b/local/src/components/local-login.test.ts index e422829..b5a895a 100644 --- a/local/src/components/local-login.test.ts +++ b/local/src/components/local-login.test.ts @@ -93,12 +93,19 @@ function response(body: unknown, ok = true) { } as unknown as Response; } -function installWindow() { +function installWindow(hash = "") { + const replaceState = vi.fn(); Object.defineProperty(globalThis, "window", { - value: { location: { href: "" } }, + value: { + location: { href: "", hash, pathname: "/login", search: "" }, + history: { replaceState }, + }, configurable: true, }); - return (globalThis as any).window as { location: { href: string } }; + return (globalThis as any).window as { + location: { href: string; hash: string; pathname: string; search: string }; + history: { replaceState: ReturnType }; + }; } function renderLogin(overrides: Record = {}, runEffects = false) { @@ -174,7 +181,7 @@ describe("local login component", () => { await mocks.forms[0].onSubmit({ preventDefault: vi.fn() }); - expect(setters[6]).toHaveBeenCalledWith("密码至少需要 10 个字符"); + expect(setters[5]).toHaveBeenCalledWith("密码至少需要 10 个字符"); expect(globalThis.fetch).not.toHaveBeenCalled(); }); @@ -190,7 +197,7 @@ describe("local login component", () => { await mocks.forms[0].onSubmit({ preventDefault: vi.fn() }); - expect(setters[6]).toHaveBeenCalledWith("两次输入的密码不一致,请重新确认"); + expect(setters[5]).toHaveBeenCalledWith("两次输入的密码不一致,请重新确认"); expect(globalThis.fetch).not.toHaveBeenCalled(); }); @@ -201,7 +208,6 @@ describe("local login component", () => { 0: { setupRequired: false, authenticated: false }, 1: "admin", 2: "secret", - 4: false, }); mocks.inputs.find((input) => input.placeholder === "管理员账号").onChange({ target: { value: "root" } }); @@ -211,8 +217,8 @@ describe("local login component", () => { expect(setters[1]).toHaveBeenCalledWith("root"); expect(setters[2]).toHaveBeenCalledWith("next"); + expect((setters[7] as any).lastValue).toBe(true); expect(setters[4]).toHaveBeenCalledWith(true); - expect(setters[5]).toHaveBeenCalledWith(true); expect(globalThis.fetch).toHaveBeenCalledWith( "/api/auth/login", expect.objectContaining({ @@ -221,7 +227,7 @@ describe("local login component", () => { }), ); expect(win.location.href).toBe("/dashboard"); - expect(setters[5]).toHaveBeenLastCalledWith(false); + expect(setters[4]).toHaveBeenLastCalledWith(false); }); it("submits setup requests and reports server or load errors", async () => { @@ -232,7 +238,9 @@ describe("local login component", () => { 1: "admin", 2: "long-secret", 3: "long-secret", - 4: true, + 6: "setup-secret", + 7: true, + 8: true, }); expect(mocks.inputs.find((input) => input.placeholder === "密码").type).toBe("text"); @@ -244,17 +252,51 @@ describe("local login component", () => { "/api/setup/admin", expect.objectContaining({ method: "POST", + headers: { + "Content-Type": "application/json", + "X-SubBoost-Setup-Token": "setup-secret", + }, body: JSON.stringify({ username: "admin", password: "long-secret", passwordConfirm: "long-secret" }), }), ); - expect(setters[6]).toHaveBeenCalledWith("用户名已存在"); - expect(setters[5]).toHaveBeenLastCalledWith(false); + expect(setters[5]).toHaveBeenCalledWith("用户名已存在"); + expect(setters[4]).toHaveBeenLastCalledWith(false); (globalThis as any).fetch = vi.fn(async () => { throw new Error("加载失败"); }); const load = renderLogin({}, true); await flushPromises(); - expect(load.setters[6]).toHaveBeenCalledWith("加载失败"); + expect(load.setters[5]).toHaveBeenCalledWith("加载失败"); + }); + + it("requires the installer fragment and clears it after successful setup", async () => { + const missingWindow = installWindow(); + (globalThis as any).fetch = vi.fn(); + const missing = renderLogin({ + 0: { setupRequired: true, authenticated: false }, + 1: "admin", + 2: "long-secret", + 3: "long-secret", + }); + expect(missing.html).toContain("安装器输出的初始化链接"); + await mocks.forms[0].onSubmit({ preventDefault: vi.fn() }); + expect(missing.setters[5]).toHaveBeenCalledWith("缺少初始化令牌,请使用安装器输出的初始化链接"); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + const win = installWindow("#setup-token=setup-secret"); + (globalThis as any).fetch = vi.fn(async () => response({ success: true })); + const setup = renderLogin({ + 0: { setupRequired: true, authenticated: false }, + 1: "admin", + 2: "long-secret", + 3: "long-secret", + 6: "setup-secret", + }); + await mocks.forms[0].onSubmit({ preventDefault: vi.fn() }); + + expect(win.history.replaceState).toHaveBeenCalledWith(null, "", "/login"); + expect(win.location.href).toBe("/dashboard"); + expect(missingWindow.history.replaceState).not.toHaveBeenCalled(); }); }); diff --git a/local/src/components/local-login.tsx b/local/src/components/local-login.tsx index b800f95..16a0b93 100644 --- a/local/src/components/local-login.tsx +++ b/local/src/components/local-login.tsx @@ -3,8 +3,12 @@ import * as React from "react"; import Image from "next/image"; import Link from "next/link"; -import { Eye, EyeOff, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { getLocalAdminSetupCredentialError, LOCAL_ADMIN_PASSWORD_MIN_LENGTH } from "@local/lib/admin-credentials"; +import { Button } from "@subboost/ui/components/ui/button"; +import { FormField } from "@subboost/ui/components/ui/form-field"; +import { Input } from "@subboost/ui/components/ui/input"; +import { PasswordField } from "@subboost/ui/components/ui/password-field"; import { hasAuthConfigHandoff } from "@subboost/ui/store/config-store/auth-handoff"; type AuthState = { @@ -26,12 +30,14 @@ export function LocalLogin() { const [username, setUsername] = React.useState(""); const [password, setPassword] = React.useState(""); const [passwordConfirm, setPasswordConfirm] = React.useState(""); - const [showPassword, setShowPassword] = React.useState(false); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(""); + const [setupToken, setSetupToken] = React.useState(""); React.useEffect(() => { let cancelled = false; + const fragment = new URLSearchParams(window.location.hash.replace(/^#/, "")); + setSetupToken(fragment.get("setup-token")?.trim() || ""); void fetch("/api/auth/me", { cache: "no-store" }) .then((response) => readJson(response)) .then((nextAuth) => { @@ -60,16 +66,26 @@ export function LocalLogin() { setError(credentialError); return; } + if (setupRequired && !setupToken) { + setError("缺少初始化令牌,请使用安装器输出的初始化链接"); + return; + } setLoading(true); try { const response = await fetch(setupRequired ? "/api/setup/admin" : "/api/auth/login", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(setupRequired ? { "X-SubBoost-Setup-Token": setupToken } : {}), + }, body: JSON.stringify({ username, password, passwordConfirm }), }); const data = await readJson<{ error?: string }>(response); if (!response.ok) throw new Error(data.error || "登录失败"); + if (setupRequired) { + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`); + } window.location.href = getPostLoginHref(); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "登录失败,请重试"); @@ -92,45 +108,37 @@ export function LocalLogin() {
{auth ? (
- + setUsername(e.target.value)} - className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-indigo-500/50 transition-colors" - /> -
- setPassword(e.target.value)} - className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-indigo-500/50 transition-colors pr-12" + className="h-12" /> - -
- {setupRequired ? ( -

- 至少 {LOCAL_ADMIN_PASSWORD_MIN_LENGTH} 个字符 + + setPassword(e.target.value)} + className="h-12" + /> + {setupRequired && !setupToken ? ( +

+ 请从安装器输出的初始化链接进入本页面。

) : null} {setupRequired ? ( - setPasswordConfirm(e.target.value)} - className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-indigo-500/50 transition-colors" + className="h-12" /> ) : null} @@ -140,14 +148,14 @@ export function LocalLogin() {

) : null} - +
) : (
diff --git a/local/src/lib/auto-update-service.test.ts b/local/src/lib/auto-update-service.test.ts index bd55ec6..27fba41 100644 --- a/local/src/lib/auto-update-service.test.ts +++ b/local/src/lib/auto-update-service.test.ts @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => ({ $transaction: vi.fn(), subscription: { findMany: vi.fn(), - update: vi.fn(), + updateMany: vi.fn(), }, subscriptionAutoUpdateState: { upsert: vi.fn(), @@ -63,6 +63,7 @@ const subscription = { createdAt: new Date("2026-06-05T00:00:00.000Z"), lastUpdatedAt: null, autoUpdateState: null, + updatedAt: new Date("2026-06-05T00:00:00.000Z"), }; function accumulator(total: number) { @@ -85,9 +86,12 @@ describe("local subscription auto update service", () => { }); mocks.finalizeCronUpdateSummary.mockImplementation((acc, options) => ({ ...acc, options })); mocks.prisma.subscription.findMany.mockResolvedValue([subscription]); - mocks.prisma.subscription.update.mockReturnValue({ update: true }); - mocks.prisma.subscriptionAutoUpdateState.upsert.mockReturnValue({ upsert: true }); - mocks.prisma.$transaction.mockResolvedValue([]); + mocks.prisma.subscription.updateMany.mockResolvedValue({ count: 1 }); + mocks.prisma.subscriptionAutoUpdateState.upsert.mockResolvedValue({ upsert: true }); + mocks.prisma.$transaction.mockImplementation(async (callback) => callback({ + subscription: { updateMany: mocks.prisma.subscription.updateMany }, + subscriptionAutoUpdateState: { upsert: mocks.prisma.subscriptionAutoUpdateState.upsert }, + })); mocks.resolveSubscriptionAutoUpdateState.mockReturnValue({ lastAttemptedAt: null, externalFailureCount: 0 }); mocks.resolveAutoUpdateScheduleState.mockReturnValue({ due: true }); mocks.readSubscriptionSecrets.mockReturnValue({ config: { rules: [] }, urls: ["https://airport.example/sub"], nodes: [] }); @@ -150,9 +154,9 @@ describe("local subscription auto update service", () => { storedNodes: [], }) ); - expect(mocks.prisma.subscription.update).toHaveBeenCalledWith( + expect(mocks.prisma.subscription.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "sub-1" }, + where: { id: "sub-1", updatedAt: subscription.updatedAt }, data: expect.objectContaining({ encryptedNodes: { encrypted: [{ name: "A" }] }, encryptedConfig: { encrypted: { rules: [], sources: [{ url: "https://airport.example/sub" }] } }, @@ -180,7 +184,7 @@ describe("local subscription auto update service", () => { expect.objectContaining({ outcomes: [{ kind: "disabled", subscriptionId: "sub-1" }] }) ); - expect(mocks.prisma.subscription.update).toHaveBeenCalledWith( + expect(mocks.prisma.subscription.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ autoUpdateInterval: null }), }) @@ -235,4 +239,18 @@ describe("local subscription auto update service", () => { expect.objectContaining({ subscriptionId: "sub-1", message: "unexpected" }) ); }); + + it("treats a CAS miss as a stale skip without publishing success", async () => { + mocks.prisma.subscription.updateMany.mockResolvedValueOnce({ count: 0 }); + + await runLocalSubscriptionAutoUpdateCron(now); + + expect(mocks.prisma.subscriptionAutoUpdateState.upsert).not.toHaveBeenCalled(); + expect(mocks.applyCronUpdateOutcome).toHaveBeenCalledWith(expect.anything(), { + status: "skipped", + requestedHosts: ["airport.example"], + recordHosts: false, + }); + expect(console.info).not.toHaveBeenCalled(); + }); }); diff --git a/local/src/lib/auto-update-service.ts b/local/src/lib/auto-update-service.ts index e3ed3ff..db45c7a 100644 --- a/local/src/lib/auto-update-service.ts +++ b/local/src/lib/auto-update-service.ts @@ -28,6 +28,7 @@ import { type SubscriptionRow, } from "./subscription-service"; import { LOCAL_AUTO_UPDATE_MIN_SECONDS } from "./auto-update-policy"; +import { JobLeaseLostError } from "./job-lease"; type AutoUpdateSubscriptionRow = SubscriptionRow & { owner: { @@ -56,20 +57,29 @@ function toCompletionTarget(subscription: AutoUpdateSubscriptionRow): AutomaticR async function writeAutoUpdateState( subscriptionId: string, + expectedUpdatedAt: Date, state: SubscriptionAutoUpdateStateFields, - extraSubscriptionData: Record = {} -) { - await prisma.$transaction([ - prisma.subscription.update({ - where: { id: subscriptionId }, - data: extraSubscriptionData, - }), - prisma.subscriptionAutoUpdateState.upsert({ + extraSubscriptionData: Record = {}, + assertLease?: () => Promise +): Promise { + await assertLease?.(); + return prisma.$transaction(async (tx) => { + const updated = await tx.subscription.updateMany({ + where: { id: subscriptionId, updatedAt: expectedUpdatedAt }, + data: { ...extraSubscriptionData, updatedAt: new Date() }, + }); + if (updated.count !== 1) return false; + await tx.subscriptionAutoUpdateState.upsert({ where: { subscriptionId }, create: { subscriptionId, ...state }, update: state, - }), - ]); + }); + return true; + }); +} + +function staleOutcome(requestedHosts: string[]): CronUpdateOutcome { + return { status: "skipped", requestedHosts, recordHosts: false }; } async function prepareLocalRefresh( @@ -110,10 +120,16 @@ async function completeAllSourcesFailed(params: { subscription: AutoUpdateSubscriptionRow; prepared: PreparedLocalRefresh; decision: Extract, { kind: "all_sources_failed" }>; + assertLease?: () => Promise; }): Promise { - await writeAutoUpdateState(params.subscription.id, params.decision.nextAutoUpdateState.state, { - ...(params.decision.nextAutoUpdateState.shouldDisableAutoUpdate ? { autoUpdateInterval: null } : {}), - }); + const persisted = await writeAutoUpdateState( + params.subscription.id, + params.subscription.updatedAt, + params.decision.nextAutoUpdateState.state, + { ...(params.decision.nextAutoUpdateState.shouldDisableAutoUpdate ? { autoUpdateInterval: null } : {}) }, + params.assertLease + ); + if (!persisted) return staleOutcome(params.prepared.requestedHosts); if (params.decision.nextAutoUpdateState.shouldDisableAutoUpdate) { console.warn("[local-subscription-cron] auto update disabled", { @@ -130,6 +146,7 @@ async function completeSuccess(params: { prepared: PreparedLocalRefresh; attemptedAt: Date; intervalSeconds: number; + assertLease?: () => Promise; }): Promise { const refreshResult = params.prepared.refreshResult; if (!refreshResult.ok) throw new Error(`Unexpected refresh failure reason: ${refreshResult.reason}`); @@ -146,14 +163,21 @@ async function completeSuccess(params: { if (decision.kind !== "success") throw new Error(`Unexpected refresh completion decision: ${decision.kind}`); const config = { ...params.prepared.config, sources: params.prepared.snapshot.savedSources }; - await writeAutoUpdateState(params.subscription.id, decision.nextAutoUpdateState.state, { - encryptedNodes: encryptJson(refreshResult.cacheEntry.nodes), - encryptedConfig: encryptJson(config), - encryptedSubscriptionInfo: encryptJson(refreshResult.cacheEntry.subscriptionInfo), - lastUpdatedAt: cachedAt, - cacheExpiresAt: buildSubscriptionCacheExpiry(cachedAt), - ...(decision.nextAutoUpdateState.shouldDisableAutoUpdate ? { autoUpdateInterval: null } : {}), - }); + const persisted = await writeAutoUpdateState( + params.subscription.id, + params.subscription.updatedAt, + decision.nextAutoUpdateState.state, + { + encryptedNodes: encryptJson(refreshResult.cacheEntry.nodes), + encryptedConfig: encryptJson(config), + encryptedSubscriptionInfo: encryptJson(refreshResult.cacheEntry.subscriptionInfo), + lastUpdatedAt: cachedAt, + cacheExpiresAt: buildSubscriptionCacheExpiry(cachedAt), + ...(decision.nextAutoUpdateState.shouldDisableAutoUpdate ? { autoUpdateInterval: null } : {}), + }, + params.assertLease + ); + if (!persisted) return staleOutcome(params.prepared.requestedHosts); console.info("[local-subscription-cron] updated", { subscriptionId: params.subscription.id, @@ -172,6 +196,7 @@ async function completeLocalRefresh(params: { prepared: PreparedLocalRefresh; attemptedAt: Date; intervalSeconds: number; + assertLease?: () => Promise; }): Promise { const refreshResult = params.prepared.refreshResult; @@ -189,7 +214,14 @@ async function completeLocalRefresh(params: { } if (decision.kind === "success") throw new Error("Unexpected successful completion decision"); - await writeAutoUpdateState(params.subscription.id, decision.attemptedState); + const persisted = await writeAutoUpdateState( + params.subscription.id, + params.subscription.updatedAt, + decision.attemptedState, + {}, + params.assertLease + ); + if (!persisted) return staleOutcome(params.prepared.requestedHosts); return decision.outcome; } @@ -201,6 +233,7 @@ async function recordUnexpectedFailure(params: { requestedHosts: string[]; error: unknown; attemptStartedAt: Date | null; + assertLease?: () => Promise; }): Promise { const completion = resolveAutomaticRefreshUnexpectedFailureCompletion({ target: toCompletionTarget(params.subscription), @@ -209,7 +242,17 @@ async function recordUnexpectedFailure(params: { attemptStartedAt: params.attemptStartedAt, }); if (completion.attemptedState) { - await writeAutoUpdateState(params.subscription.id, completion.attemptedState).catch(() => undefined); + const persisted = await writeAutoUpdateState( + params.subscription.id, + params.subscription.updatedAt, + completion.attemptedState, + {}, + params.assertLease + ).catch((error) => { + if (error instanceof JobLeaseLostError) throw error; + return null; + }); + if (persisted === false) return staleOutcome(params.requestedHosts); } console.error("[local-subscription-cron] failed", { @@ -220,7 +263,11 @@ async function recordUnexpectedFailure(params: { return completion.outcome; } -export async function runLocalSubscriptionAutoUpdateCron(now = new Date()): Promise { +export async function runLocalSubscriptionAutoUpdateCron( + now = new Date(), + options: { assertLease?: () => Promise } = {} +): Promise { + await options.assertLease?.(); const subscriptions = (await prisma.subscription.findMany({ where: { autoUpdateInterval: { not: null } }, include: { owner: { select: { username: true } }, autoUpdateState: true }, @@ -228,6 +275,7 @@ export async function runLocalSubscriptionAutoUpdateCron(now = new Date()): Prom const accumulator = createCronUpdateAccumulator(subscriptions.length); for (const subscription of subscriptions) { + await options.assertLease?.(); let requestedHosts: string[] = []; let attemptStartedAt: Date | null = null; try { @@ -258,15 +306,24 @@ export async function runLocalSubscriptionAutoUpdateCron(now = new Date()): Prom prepared, attemptedAt: attemptStartedAt, intervalSeconds, + assertLease: options.assertLease, }); applyCronUpdateOutcome(accumulator, outcome); } catch (error) { + if (error instanceof JobLeaseLostError) throw error; applyCronUpdateOutcome( accumulator, - await recordUnexpectedFailure({ subscription, requestedHosts, error, attemptStartedAt }) + await recordUnexpectedFailure({ + subscription, + requestedHosts, + error, + attemptStartedAt, + assertLease: options.assertLease, + }) ); } } + await options.assertLease?.(); return finalizeCronUpdateSummary(accumulator, { maxTopHosts: 50, maxTopUsers: 50 }); } diff --git a/local/src/lib/cron-auth.ts b/local/src/lib/cron-auth.ts index c4e86ae..f8912e7 100644 --- a/local/src/lib/cron-auth.ts +++ b/local/src/lib/cron-auth.ts @@ -20,7 +20,7 @@ export function requireLocalCronAuth(request: Request): ReturnType { - const text = await request.text(); - if (!text.trim()) return {}; +export async function readJsonBody(request: Request, maxBytes: number): Promise { + const limit = Math.max(1, Math.floor(maxBytes)); + const declaredLength = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > limit) { + return { ok: false, reason: "too_large" }; + } + + if (!request.body) return { ok: true, value: {} }; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; try { - return JSON.parse(text); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > limit) { + await reader.cancel("payload too large").catch(() => undefined); + return { ok: false, reason: "too_large" }; + } + chunks.push(value); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (!text.trim()) return { ok: true, value: {} }; + return { ok: true, value: JSON.parse(text) }; } catch { - return null; + return { ok: false, reason: "invalid_json" }; } } +export function jsonBodyError( + result: Extract, + invalidJsonMessage = "Invalid JSON body." +): NextResponse { + return result.reason === "too_large" + ? apiError("Request body is too large.", "PAYLOAD_TOO_LARGE", 413) + : apiError(invalidJsonMessage, "BAD_REQUEST", 400); +} + export function getStringField(body: unknown, key: string): string { if (!body || typeof body !== "object" || Array.isArray(body)) return ""; const value = (body as Record)[key]; diff --git a/local/src/lib/job-lease.test.ts b/local/src/lib/job-lease.test.ts new file mode 100644 index 0000000..a3a1513 --- /dev/null +++ b/local/src/lib/job-lease.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + prisma: { + $queryRaw: vi.fn(), + $executeRaw: vi.fn(), + }, +})); + +vi.mock("./prisma", () => ({ prisma: mocks.prisma })); + +import { + acquireLocalJobLease, + releaseLocalJobLease, + renewLocalJobLease, + startLocalJobLeaseHeartbeat, +} from "./job-lease"; + +describe("local job lease", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-16T00:00:00.000Z")); + }); + + afterEach(() => vi.useRealTimers()); + + it("acquires only when the conditional database upsert returns a row", async () => { + mocks.prisma.$queryRaw + .mockResolvedValueOnce([{ name: "local-cron", ownerToken: "owner", expiresAt: new Date() }]) + .mockResolvedValueOnce([]); + + await expect(acquireLocalJobLease({ name: "local-cron", leaseMs: 300_000 })).resolves.toMatchObject({ + name: "local-cron", + }); + await expect(acquireLocalJobLease({ name: "local-cron", leaseMs: 300_000 })).resolves.toBeNull(); + }); + + it("renews and releases only the current owner", async () => { + const lease = { name: "local-cron", ownerToken: "owner", expiresAt: new Date() }; + mocks.prisma.$executeRaw.mockResolvedValueOnce(1).mockResolvedValueOnce(1); + + await expect(renewLocalJobLease(lease, 300_000)).resolves.toBe(true); + await expect(releaseLocalJobLease(lease)).resolves.toBeUndefined(); + expect(mocks.prisma.$executeRaw).toHaveBeenCalledTimes(2); + }); + + it("fails closed when heartbeat renewal loses ownership", async () => { + const lease = { name: "local-cron", ownerToken: "owner", expiresAt: new Date() }; + mocks.prisma.$executeRaw.mockResolvedValueOnce(0); + const heartbeat = startLocalJobLeaseHeartbeat({ lease, leaseMs: 300_000, intervalMs: 60_000 }); + + await expect(heartbeat.assertOwned()).rejects.toThrow("Local job lease lost"); + await heartbeat.stop(); + }); +}); diff --git a/local/src/lib/job-lease.ts b/local/src/lib/job-lease.ts new file mode 100644 index 0000000..859d209 --- /dev/null +++ b/local/src/lib/job-lease.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; +import { prisma } from "./prisma"; + +export type LocalJobLease = { + name: string; + ownerToken: string; + expiresAt: Date; +}; + +export class JobLeaseLostError extends Error { + constructor(name: string) { + super(`Local job lease lost: ${name}`); + this.name = "JobLeaseLostError"; + } +} + +export async function acquireLocalJobLease(params: { + name: string; + leaseMs: number; + now?: Date; +}): Promise { + const now = params.now ?? new Date(); + const ownerToken = randomUUID(); + const expiresAt = new Date(now.getTime() + params.leaseMs); + const rows = await prisma.$queryRaw` + INSERT INTO "JobLeaseLock" ("name", "ownerToken", "expiresAt", "updatedAt") + VALUES (${params.name}, ${ownerToken}, ${expiresAt}, ${now}) + ON CONFLICT ("name") DO UPDATE + SET "ownerToken" = EXCLUDED."ownerToken", + "expiresAt" = EXCLUDED."expiresAt", + "updatedAt" = EXCLUDED."updatedAt" + WHERE "JobLeaseLock"."expiresAt" <= ${now} + RETURNING "name", "ownerToken", "expiresAt" + `; + return rows[0] ?? null; +} + +export async function renewLocalJobLease( + lease: LocalJobLease, + leaseMs: number, + now = new Date() +): Promise { + const expiresAt = new Date(now.getTime() + leaseMs); + const count = await prisma.$executeRaw` + UPDATE "JobLeaseLock" + SET "expiresAt" = ${expiresAt}, "updatedAt" = ${now} + WHERE "name" = ${lease.name} + AND "ownerToken" = ${lease.ownerToken} + AND "expiresAt" > ${now} + `; + if (count === 1) lease.expiresAt = expiresAt; + return count === 1; +} + +export async function releaseLocalJobLease(lease: LocalJobLease): Promise { + await prisma.$executeRaw` + DELETE FROM "JobLeaseLock" + WHERE "name" = ${lease.name} AND "ownerToken" = ${lease.ownerToken} + `; +} + +export function startLocalJobLeaseHeartbeat(params: { + lease: LocalJobLease; + leaseMs: number; + intervalMs: number; +}): { assertOwned: () => Promise; stop: () => Promise } { + let stopped = false; + let lost = false; + let pending: Promise = Promise.resolve(); + + const renew = async () => { + if (stopped) return; + if (lost || !(await renewLocalJobLease(params.lease, params.leaseMs))) { + lost = true; + throw new JobLeaseLostError(params.lease.name); + } + }; + const enqueue = () => { + const next = pending.then(renew); + pending = next.catch(() => undefined); + return next; + }; + const timer = setInterval(() => { + void enqueue().catch(() => undefined); + }, params.intervalMs); + timer.unref?.(); + + return { + assertOwned: async () => { + if (stopped || lost) throw new JobLeaseLostError(params.lease.name); + await enqueue(); + }, + stop: async () => { + stopped = true; + clearInterval(timer); + await pending; + }, + }; +} diff --git a/local/src/lib/local-helpers.test.ts b/local/src/lib/local-helpers.test.ts index 085a0c4..42080ce 100644 --- a/local/src/lib/local-helpers.test.ts +++ b/local/src/lib/local-helpers.test.ts @@ -164,9 +164,20 @@ describe("local lib helpers", () => { status: 400, body: { error: "bad", code: "BAD_REQUEST" }, }); - await expect(readJsonBody(new Request("https://local.test", { method: "POST", body: "" }))).resolves.toEqual({}); - await expect(readJsonBody(new Request("https://local.test", { method: "POST", body: "{bad" }))).resolves.toBeNull(); - await expect(readJsonBody(new Request("https://local.test", { method: "POST", body: '{"name":" Ry "}' }))).resolves.toEqual({ name: " Ry " }); + await expect(readJsonBody(new Request("https://local.test", { method: "POST", body: "" }), 1024)).resolves.toEqual({ + ok: true, + value: {}, + }); + await expect(readJsonBody(new Request("https://local.test", { method: "POST", body: "{bad" }), 1024)).resolves.toEqual({ + ok: false, + reason: "invalid_json", + }); + await expect( + readJsonBody(new Request("https://local.test", { method: "POST", body: '{"name":" Ry "}' }), 1024) + ).resolves.toEqual({ ok: true, value: { name: " Ry " } }); + await expect( + readJsonBody(new Request("https://local.test", { method: "POST", body: '{"too":"large"}' }), 4) + ).resolves.toEqual({ ok: false, reason: "too_large" }); expect(getStringField({ name: " Ry " }, "name")).toBe("Ry"); expect(getStringField({ name: 1 }, "name")).toBe(""); expect(getStringField(null, "name")).toBe(""); diff --git a/local/src/lib/pinned-http.test.ts b/local/src/lib/pinned-http.test.ts new file mode 100644 index 0000000..15174ba --- /dev/null +++ b/local/src/lib/pinned-http.test.ts @@ -0,0 +1,55 @@ +import { createServer, type Server } from "node:http"; +import { gzipSync } from "node:zlib"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { requestPinnedText, ResponseTooLargeError } from "./pinned-http"; + +describe("pinned local HTTP transport", () => { + let server: Server; + let port = 0; + let observedHost = ""; + + beforeAll(async () => { + server = createServer((request, response) => { + observedHost = request.headers.host || ""; + const body = request.url === "/large" ? "x".repeat(2048) : "ss://node"; + response.writeHead(200, { + "Content-Type": "text/plain", + "Content-Encoding": "gzip", + }); + response.end(gzipSync(body)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test server address"); + port = address.port; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + }); + + it("connects only to the validated address while preserving the original Host header", async () => { + const response = await requestPinnedText({ + url: `http://example.test:${port}/ok`, + addresses: ["127.0.0.1"], + method: "GET", + userAgent: "SubBoost Test", + maxBytes: 1024, + signal: new AbortController().signal, + }); + + expect(response).toMatchObject({ status: 200, content: "ss://node" }); + expect(observedHost).toBe(`example.test:${port}`); + }); + + it("enforces the limit after response decompression", async () => { + await expect(requestPinnedText({ + url: `http://example.test:${port}/large`, + addresses: ["127.0.0.1"], + method: "GET", + userAgent: "SubBoost Test", + maxBytes: 128, + signal: new AbortController().signal, + })).rejects.toBeInstanceOf(ResponseTooLargeError); + }); +}); diff --git a/local/src/lib/pinned-http.ts b/local/src/lib/pinned-http.ts new file mode 100644 index 0000000..59d03fd --- /dev/null +++ b/local/src/lib/pinned-http.ts @@ -0,0 +1,123 @@ +import { isIP } from "node:net"; +import { request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; + +export class ResponseTooLargeError extends Error { + constructor() { + super("Subscription response exceeds the configured byte limit"); + this.name = "ResponseTooLargeError"; + } +} + +export type DirectHttpResponse = { + status: number; + headers: Record; + content: string; +}; + +function normalizeHeaders(headers: IncomingHttpHeaders): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + out[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value); + } + return out; +} + +async function readLimitedBody(response: IncomingMessage, maxBytes: number): Promise { + const encoding = String(response.headers["content-encoding"] || "").trim().toLowerCase(); + const decoded = encoding === "gzip" || encoding === "x-gzip" + ? response.pipe(createGunzip()) + : encoding === "deflate" + ? response.pipe(createInflate()) + : encoding === "br" + ? response.pipe(createBrotliDecompress()) + : response; + + const chunks: Buffer[] = []; + let total = 0; + try { + for await (const raw of decoded) { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + total += chunk.byteLength; + if (total > maxBytes) throw new ResponseTooLargeError(); + chunks.push(chunk); + } + } catch (error) { + response.destroy(); + if (decoded !== response) decoded.destroy(); + throw error; + } + return Buffer.concat(chunks, total).toString("utf8"); +} + +function openPinnedRequest(params: { + parsed: URL; + address: string; + method: "GET" | "HEAD"; + userAgent: string; + signal: AbortSignal; +}): Promise { + const requestImpl = params.parsed.protocol === "https:" ? httpsRequest : httpRequest; + return new Promise((resolve, reject) => { + const request = requestImpl({ + protocol: params.parsed.protocol, + hostname: params.address, + family: isIP(params.address) || undefined, + port: params.parsed.port || undefined, + path: `${params.parsed.pathname}${params.parsed.search}`, + method: params.method, + headers: { + Host: params.parsed.host, + "User-Agent": params.userAgent, + Accept: "text/plain, application/yaml, application/x-yaml, */*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + }, + signal: params.signal, + ...(params.parsed.protocol === "https:" && !isIP(params.parsed.hostname) + ? { servername: params.parsed.hostname } + : {}), + }, resolve); + request.once("error", reject); + request.end(); + }); +} + +export async function requestPinnedText(params: { + url: string; + addresses: readonly string[]; + method: "GET" | "HEAD"; + userAgent: string; + maxBytes: number; + signal: AbortSignal; +}): Promise { + const parsed = new URL(params.url); + let lastError: unknown = new Error("No validated address is available"); + let response: IncomingMessage | null = null; + for (const address of params.addresses) { + try { + response = await openPinnedRequest({ ...params, parsed, address }); + break; + } catch (error) { + lastError = error; + if (params.signal.aborted) throw error; + } + } + if (!response) throw lastError; + + const headers = normalizeHeaders(response.headers); + if ((response.statusCode || 0) >= 300 && (response.statusCode || 0) < 400) { + response.destroy(); + return { status: response.statusCode || 0, headers, content: "" }; + } + const contentLength = Number(headers["content-length"] || "0"); + if (Number.isFinite(contentLength) && contentLength > params.maxBytes) { + response.destroy(); + throw new ResponseTooLargeError(); + } + const content = params.method === "HEAD" ? "" : await readLimitedBody(response, params.maxBytes); + if (params.method === "HEAD") response.resume(); + return { status: response.statusCode || 0, headers, content }; +} diff --git a/local/src/lib/rate-limit.test.ts b/local/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..52a32d4 --- /dev/null +++ b/local/src/lib/rate-limit.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearLocalRateLimitsForTests, + consumeLocalRateLimit, + getTrustedClientRateLimitKey, + hashLocalRateLimitKey, + localRateLimitResponse, + resetLocalRateLimit, +} from "./rate-limit"; + +describe("local rate limits", () => { + beforeEach(() => clearLocalRateLimitsForTests()); + + it("blocks requests above a fixed-window limit and resets after expiry", () => { + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 1_000 }).allowed).toBe(true); + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 2_000 }).allowed).toBe(true); + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 3_000 })).toEqual({ + allowed: false, + retryAfterSeconds: 8, + }); + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 11_000 }).allowed).toBe(true); + }); + + it("hashes sensitive keys and supports clearing successful identity buckets", () => { + const key = hashLocalRateLimitKey("secret-token"); + expect(key).toMatch(/^[a-f0-9]{64}$/); + expect(key).not.toContain("secret-token"); + consumeLocalRateLimit("token", key, { limit: 1, windowMs: 10_000, now: 1_000 }); + expect(consumeLocalRateLimit("token", key, { limit: 1, windowMs: 10_000, now: 2_000 }).allowed).toBe(false); + resetLocalRateLimit("token", key); + expect(consumeLocalRateLimit("token", key, { limit: 1, windowMs: 10_000, now: 2_000 }).allowed).toBe(true); + }); + + it("returns a structured 429 response with Retry-After", async () => { + const response = localRateLimitResponse("slow down", 7); + expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe("7"); + await expect(response.json()).resolves.toEqual({ error: "slow down", code: "RATE_LIMITED" }); + }); + + it("uses only runtime or explicitly trusted proxy client addresses", () => { + const direct = new Request("https://local.test"); + expect(getTrustedClientRateLimitKey(direct)).toBeNull(); + + const runtime = Object.assign(new Request("https://local.test"), { ip: "203.0.113.10" }); + expect(getTrustedClientRateLimitKey(runtime)).toBe(hashLocalRateLimitKey("203.0.113.10")); + + process.env.TRUST_PROXY_HEADERS = "true"; + const proxied = new Request("https://local.test", { headers: { "x-forwarded-for": "198.51.100.2, 10.0.0.1" } }); + expect(getTrustedClientRateLimitKey(proxied)).toBe(hashLocalRateLimitKey("198.51.100.2")); + delete process.env.TRUST_PROXY_HEADERS; + }); + + it("caps in-memory buckets at 10,000 by evicting the earliest expiry", () => { + for (let index = 0; index < 10_000; index += 1) { + consumeLocalRateLimit("bounded", String(index), { limit: 1, windowMs: 60_000, now: 1_000 }); + } + consumeLocalRateLimit("bounded", "overflow", { limit: 1, windowMs: 60_000, now: 1_000 }); + + expect(consumeLocalRateLimit("bounded", "0", { limit: 1, windowMs: 60_000, now: 1_001 }).allowed).toBe(true); + expect(consumeLocalRateLimit("bounded", "9999", { limit: 1, windowMs: 60_000, now: 1_001 }).allowed).toBe(false); + }); +}); diff --git a/local/src/lib/rate-limit.ts b/local/src/lib/rate-limit.ts new file mode 100644 index 0000000..4edbc2e --- /dev/null +++ b/local/src/lib/rate-limit.ts @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; +import { apiError } from "./http"; + +type RateLimitBucket = { + count: number; + resetAt: number; +}; + +export type LocalRateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); +const MAX_BUCKETS = 10_000; +let nextCleanupAt = 0; + +function cleanupExpiredBuckets(now: number, force = false): void { + if (!force && (buckets.size < 1024 || now < nextCleanupAt)) return; + for (const [key, bucket] of buckets) { + if (bucket.resetAt <= now) buckets.delete(key); + } + nextCleanupAt = now + 60_000; +} + +function makeRoomForBucket(): void { + while (buckets.size >= MAX_BUCKETS) { + let earliestKey: string | null = null; + let earliestResetAt = Number.POSITIVE_INFINITY; + for (const [key, bucket] of buckets) { + if (bucket.resetAt < earliestResetAt) { + earliestKey = key; + earliestResetAt = bucket.resetAt; + } + } + if (!earliestKey) return; + buckets.delete(earliestKey); + } +} + +export function hashLocalRateLimitKey(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function consumeLocalRateLimit( + scope: string, + key: string, + options: { limit: number; windowMs: number; now?: number } +): LocalRateLimitResult { + const now = options.now ?? Date.now(); + const limit = Math.max(1, Math.floor(options.limit)); + const windowMs = Math.max(1000, Math.floor(options.windowMs)); + cleanupExpiredBuckets(now); + + const bucketKey = `${scope}:${key}`; + const current = buckets.get(bucketKey); + if (!current || current.resetAt <= now) { + if (!current && buckets.size >= MAX_BUCKETS) { + cleanupExpiredBuckets(now, true); + makeRoomForBucket(); + } + buckets.set(bucketKey, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterSeconds: 0 }; + } + + current.count += 1; + if (current.count <= limit) return { allowed: true, retryAfterSeconds: 0 }; + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; +} + +export function getTrustedClientRateLimitKey(request: Request): string | null { + const runtimeIp = (request as Request & { ip?: unknown }).ip; + if (typeof runtimeIp === "string" && runtimeIp.trim()) { + return hashLocalRateLimitKey(runtimeIp.trim()); + } + if (process.env.TRUST_PROXY_HEADERS !== "true") return null; + + const forwarded = request.headers.get("cf-connecting-ip") + || request.headers.get("x-real-ip") + || request.headers.get("x-forwarded-for")?.split(",")[0] + || ""; + const normalized = forwarded.trim(); + return normalized ? hashLocalRateLimitKey(normalized) : null; +} + +export function resetLocalRateLimit(scope: string, key: string): void { + buckets.delete(`${scope}:${key}`); +} + +export function localRateLimitResponse(message: string, retryAfterSeconds: number): Response { + const response = apiError(message, "RATE_LIMITED", 429); + response.headers.set("Retry-After", String(Math.max(1, retryAfterSeconds))); + return response; +} + +export function clearLocalRateLimitsForTests(): void { + if (process.env.NODE_ENV === "test") { + buckets.clear(); + nextCleanupAt = 0; + } +} diff --git a/local/src/lib/setup-token.test.ts b/local/src/lib/setup-token.test.ts new file mode 100644 index 0000000..fda81b5 --- /dev/null +++ b/local/src/lib/setup-token.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { LOCAL_SETUP_TOKEN_HEADER, validateLocalSetupToken } from "./setup-token"; + +describe("local setup token", () => { + const original = process.env.LOCAL_SETUP_TOKEN; + + afterEach(() => { + if (original === undefined) delete process.env.LOCAL_SETUP_TOKEN; + else process.env.LOCAL_SETUP_TOKEN = original; + }); + + it("fails closed when the operator token is missing", () => { + delete process.env.LOCAL_SETUP_TOKEN; + expect(validateLocalSetupToken(new Request("https://local.test"))).toBe("missing_config"); + }); + + it("accepts only an exact setup token supplied through the dedicated header", () => { + process.env.LOCAL_SETUP_TOKEN = "expected-secret"; + expect(validateLocalSetupToken(new Request("https://local.test"))).toBe("invalid"); + expect(validateLocalSetupToken(new Request("https://local.test", { + headers: { [LOCAL_SETUP_TOKEN_HEADER]: "wrong-secret" }, + }))).toBe("invalid"); + expect(validateLocalSetupToken(new Request("https://local.test", { + headers: { [LOCAL_SETUP_TOKEN_HEADER]: "expected-secret" }, + }))).toBe("valid"); + }); +}); diff --git a/local/src/lib/setup-token.ts b/local/src/lib/setup-token.ts new file mode 100644 index 0000000..5e65661 --- /dev/null +++ b/local/src/lib/setup-token.ts @@ -0,0 +1,15 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +export const LOCAL_SETUP_TOKEN_HEADER = "x-subboost-setup-token"; + +export function validateLocalSetupToken(request: Request): "valid" | "invalid" | "missing_config" { + const expected = process.env.LOCAL_SETUP_TOKEN; + if (!expected) return "missing_config"; + + const supplied = request.headers.get(LOCAL_SETUP_TOKEN_HEADER); + if (!supplied) return "invalid"; + + const expectedDigest = createHash("sha256").update(expected).digest(); + const suppliedDigest = createHash("sha256").update(supplied).digest(); + return timingSafeEqual(expectedDigest, suppliedDigest) ? "valid" : "invalid"; +} diff --git a/local/src/lib/source-import-settings.test.ts b/local/src/lib/source-import-settings.test.ts new file mode 100644 index 0000000..a5031ad --- /dev/null +++ b/local/src/lib/source-import-settings.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findFirst: vi.fn(), +})); + +vi.mock("./prisma", () => ({ + prisma: { + localAdmin: { findFirst: mocks.findFirst }, + }, +})); + +import { getAllowUnsafeSubscriptionSources } from "./source-import-settings"; + +describe("local source import settings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("defaults to disabled when no local administrator exists", async () => { + mocks.findFirst.mockResolvedValueOnce(null); + + await expect(getAllowUnsafeSubscriptionSources()).resolves.toBe(false); + }); + + it("reads the persisted local administrator setting", async () => { + mocks.findFirst.mockResolvedValueOnce({ allowUnsafeSubscriptionSources: true }); + + await expect(getAllowUnsafeSubscriptionSources()).resolves.toBe(true); + expect(mocks.findFirst).toHaveBeenCalledWith({ + select: { allowUnsafeSubscriptionSources: true }, + }); + }); +}); diff --git a/local/src/lib/source-import-settings.ts b/local/src/lib/source-import-settings.ts new file mode 100644 index 0000000..3f083c1 --- /dev/null +++ b/local/src/lib/source-import-settings.ts @@ -0,0 +1,9 @@ +import { prisma } from "./prisma"; + +export async function getAllowUnsafeSubscriptionSources(): Promise { + const admin = await prisma.localAdmin.findFirst({ + select: { allowUnsafeSubscriptionSources: true }, + }); + + return admin?.allowUnsafeSubscriptionSources ?? false; +} diff --git a/local/src/lib/source-import.test.ts b/local/src/lib/source-import.test.ts index e8db443..783a889 100644 --- a/local/src/lib/source-import.test.ts +++ b/local/src/lib/source-import.test.ts @@ -2,14 +2,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchSourceUserInfoHeadersDirect, importSourceUrlDirect } from "./source-import"; const mocks = vi.hoisted(() => ({ + getAllowUnsafeSubscriptionSources: vi.fn(), lookup: vi.fn(), importSubscriptionFromUrl: vi.fn(), + requestPinnedText: vi.fn(), })); vi.mock("node:dns/promises", () => ({ lookup: mocks.lookup, })); +vi.mock("@local/lib/source-import-settings", () => ({ + getAllowUnsafeSubscriptionSources: mocks.getAllowUnsafeSubscriptionSources, +})); + +vi.mock("./pinned-http", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requestPinnedText: mocks.requestPinnedText }; +}); + vi.mock("@subboost/server-core/subscription", async (importOriginal) => { const actual = await importOriginal(); return { @@ -59,7 +70,9 @@ async function runTransport(url: string, overrides: Record = {} describe("local source import transport", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.lookup.mockResolvedValue([{ address: "93.184.216.34" }]); + mocks.getAllowUnsafeSubscriptionSources.mockResolvedValue(false); + mocks.lookup.mockRejectedValue(new Error("dns unavailable")); + mocks.requestPinnedText.mockResolvedValue({ status: 200, headers: {}, content: "ss://node" }); globalThis.fetch = vi.fn(); }); @@ -108,12 +121,43 @@ describe("local source import transport", () => { expect(globalThis.fetch).not.toHaveBeenCalled(); }); + it("allows all local and reserved address sources when the high-risk setting is enabled", async () => { + mocks.getAllowUnsafeSubscriptionSources.mockResolvedValue(true); + const allowedUrls = [ + "http://localhost:8080/sub", + "http://127.0.0.1/sub", + "http://[::1]/sub", + "http://router.local/sub", + "http://10.1.2.3/sub", + "http://169.254.169.254/latest/meta-data", + "http://100.64.0.1/sub", + "http://192.0.2.1/sub", + "http://198.18.0.1/sub", + "http://224.0.0.1/sub", + "http://[fc00::1]/sub", + "http://[fe80::1]/sub", + "http://[::ffff:192.168.0.1]/sub", + ]; + + for (const url of allowedUrls) { + vi.mocked(globalThis.fetch).mockResolvedValueOnce(response("ss://node", { status: 200 })); + await expect(runTransport(url)).resolves.toMatchObject({ ok: true, content: "ss://node" }); + } + expect(mocks.lookup).not.toHaveBeenCalled(); + }); + + it("keeps URL scheme and embedded-credential checks enabled for high-risk sources", async () => { + mocks.getAllowUnsafeSubscriptionSources.mockResolvedValue(true); + await expect(runTransport("ftp://127.0.0.1/sub")).resolves.toMatchObject({ ok: false }); + await expect(runTransport("http://user:pass@127.0.0.1/sub")).resolves.toMatchObject({ ok: false }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + it("rechecks fake-ip DNS answers with DoH before fetching the subscription", async () => { mocks.lookup.mockResolvedValueOnce([{ address: "198.18.3.6" }]); vi.mocked(globalThis.fetch) .mockResolvedValueOnce(dnsResponse(["93.184.216.34"])) - .mockResolvedValueOnce(dnsResponse([])) - .mockResolvedValueOnce(response("ss://node", { status: 200 })); + .mockResolvedValueOnce(dnsResponse([])); await expect(runTransport("https://api.dler.io/sub")).resolves.toMatchObject({ ok: true, @@ -131,11 +175,54 @@ describe("local source import transport", () => { body: expect.any(ArrayBuffer), }) ); - expect(globalThis.fetch).toHaveBeenNthCalledWith( - 3, - "https://api.dler.io/sub", - expect.objectContaining({ method: "GET", redirect: "manual" }) - ); + expect(mocks.requestPinnedText).toHaveBeenCalledWith(expect.objectContaining({ + url: "https://api.dler.io/sub", + addresses: ["93.184.216.34"], + method: "GET", + })); + }); + + it("pins ordinary successful DNS validation results into the connection", async () => { + mocks.lookup.mockResolvedValueOnce([{ address: "93.184.216.34" }]); + + await expect(runTransport("https://example.com/sub")).resolves.toMatchObject({ + ok: true, + content: "ss://node", + }); + + expect(mocks.requestPinnedText).toHaveBeenCalledWith(expect.objectContaining({ + url: "https://example.com/sub", + addresses: ["93.184.216.34"], + maxBytes: 1024, + })); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("revalidates and pins every redirect target independently", async () => { + mocks.lookup + .mockResolvedValueOnce([{ address: "93.184.216.34" }]) + .mockResolvedValueOnce([{ address: "1.1.1.1" }]); + mocks.requestPinnedText + .mockResolvedValueOnce({ + status: 302, + headers: { location: "https://cdn.example.net/sub" }, + content: "", + }) + .mockResolvedValueOnce({ status: 200, headers: {}, content: "ss://node" }); + + await expect(runTransport("https://example.com/sub")).resolves.toMatchObject({ + ok: true, + content: "ss://node", + }); + + expect(mocks.requestPinnedText).toHaveBeenNthCalledWith(1, expect.objectContaining({ + url: "https://example.com/sub", + addresses: ["93.184.216.34"], + })); + expect(mocks.requestPinnedText).toHaveBeenNthCalledWith(2, expect.objectContaining({ + url: "https://cdn.example.net/sub", + addresses: ["1.1.1.1"], + })); }); it("keeps blocking fake-ip DNS answers when DoH confirms an unsafe target", async () => { @@ -151,6 +238,21 @@ describe("local source import transport", () => { expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); + it("lets the explicit high-risk setting bypass fake-ip DNS blocking", async () => { + mocks.getAllowUnsafeSubscriptionSources.mockResolvedValue(true); + vi.mocked(globalThis.fetch).mockResolvedValueOnce(response("ss://node", { status: 200 })); + + await expect(runTransport("https://fake-ip-private.example/sub")).resolves.toMatchObject({ + ok: true, + content: "ss://node", + }); + expect(mocks.lookup).not.toHaveBeenCalled(); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://fake-ip-private.example/sub", + expect.objectContaining({ method: "GET", redirect: "manual" }) + ); + }); + it("rejects additional private IPv4 and IPv6 address ranges", async () => { const blockedUrls = [ "http://0.0.0.0/sub", @@ -274,7 +376,9 @@ describe("local source import transport", () => { }); it("handles redirect loops, missing locations, and thrown fetch errors", async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(response("", { status: 302, headers: { location: "/next" } })); + vi.mocked(globalThis.fetch).mockImplementation(async () => ( + response("", { status: 302, headers: { location: "/next" } }) + )); await expect(runTransport("https://example.com/loop")).resolves.toMatchObject({ ok: false, publicReason: "HTTP 310", @@ -303,6 +407,19 @@ describe("local source import transport", () => { }); }); + it("applies the enabled high-risk setting to redirect targets", async () => { + mocks.getAllowUnsafeSubscriptionSources.mockResolvedValue(true); + vi.mocked(globalThis.fetch) + .mockResolvedValueOnce(response("", { status: 302, headers: { location: "http://127.0.0.1/sub" } })) + .mockResolvedValueOnce(response("ss://node", { status: 200 })); + + await expect(runTransport("https://example.com/private-redirect")).resolves.toMatchObject({ + ok: true, + content: "ss://node", + }); + expect(mocks.getAllowUnsafeSubscriptionSources).toHaveBeenCalledTimes(1); + }); + it("returns userinfo headers with HEAD and skips when no userinfo URL is configured", async () => { await expect(fetchSourceUserInfoHeadersDirect({})).resolves.toBeUndefined(); @@ -338,5 +455,13 @@ describe("local source import transport", () => { headers: expect.objectContaining({ "User-Agent": expect.any(String) }), }) ); + + mocks.getAllowUnsafeSubscriptionSources.mockResolvedValue(true); + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + response("", { status: 200, headers: { "Subscription-Userinfo": "upload=2; total=4096" } }) + ); + await expect(fetchSourceUserInfoHeadersDirect({ userinfoUrl: "http://127.0.0.1/userinfo" })).resolves.toMatchObject({ + "subscription-userinfo": "upload=2; total=4096", + }); }); }); diff --git a/local/src/lib/source-import.ts b/local/src/lib/source-import.ts index 81a6667..f4e7abb 100644 --- a/local/src/lib/source-import.ts +++ b/local/src/lib/source-import.ts @@ -1,4 +1,5 @@ import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { createSubscriptionImportErrorInfo, inferSubscriptionImportErrorCategory, @@ -19,6 +20,8 @@ import { selectDnsAddressesAfterFakeIpRecheck, shouldRecheckFakeIpDnsAnswers, } from "@subboost/server-core/subscription/ssrf-ip"; +import { getAllowUnsafeSubscriptionSources } from "./source-import-settings"; +import { requestPinnedText, ResponseTooLargeError, type DirectHttpResponse } from "./pinned-http"; const DEFAULT_TIMEOUT_MS = 15000; const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; @@ -68,32 +71,47 @@ function normalizeHostname(hostname: string): string { return hostname.replace(/^\[|\]$/g, "").toLowerCase(); } -async function validatePublicFetchTarget(url: string): Promise { +async function validatePublicFetchTarget( + url: string, + allowUnsafeSubscriptionSources: boolean +): Promise< + | { ok: true; addresses: string[] | null } + | { ok: false; failure: SourceImportTransportResult } +> { let parsed: URL; try { parsed = new URL(url); } catch { - return toSecurityFailure("无效的订阅 URL"); + return { ok: false, failure: toSecurityFailure("无效的订阅 URL") }; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return toSecurityFailure("只支持 HTTP 或 HTTPS 订阅 URL"); + return { ok: false, failure: toSecurityFailure("只支持 HTTP 或 HTTPS 订阅 URL") }; } if (parsed.username || parsed.password) { - return toSecurityFailure("订阅 URL 不允许包含用户名或密码"); + return { ok: false, failure: toSecurityFailure("订阅 URL 不允许包含用户名或密码") }; } + if (allowUnsafeSubscriptionSources) return { ok: true, addresses: null }; + const hostname = normalizeHostname(parsed.hostname); if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) { - return toSecurityFailure("禁止访问本机或内网地址"); + return { ok: false, failure: toSecurityFailure("禁止访问本机或内网地址") }; } if (isPrivateOrReservedIp(hostname)) { - return toSecurityFailure("禁止访问本机或内网地址"); + return { ok: false, failure: toSecurityFailure("禁止访问本机或内网地址") }; } - const records = await lookup(hostname, { all: true, verbatim: true }).catch(() => []); + if (isIP(hostname)) return { ok: true, addresses: [hostname] }; + + const records = await lookup(hostname, { all: true, verbatim: true }).catch(() => null); + if (!records || records.length === 0) { + // Compatibility fallback documented for local: if DNS validation cannot + // obtain any address, let the runtime resolver make the connection. + return { ok: true, addresses: null }; + } const addresses = normalizeResolvedIpAddresses(records.map((record) => record.address)); const finalAddresses = shouldRecheckFakeIpDnsAnswers(addresses) ? selectDnsAddressesAfterFakeIpRecheck( @@ -103,53 +121,109 @@ async function validatePublicFetchTarget(url: string): Promise 0 ? finalAddresses : addresses; if (addressesToCheck.some((address) => isPrivateOrReservedIp(address))) { - return toSecurityFailure("禁止访问本机或内网地址"); + return { ok: false, failure: toSecurityFailure("禁止访问本机或内网地址") }; + } + + return { ok: true, addresses: addressesToCheck.length > 0 ? addressesToCheck : null }; +} + +async function readFetchBodyLimited(response: Response, maxBytes: number): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel("response too large").catch(() => undefined); + throw new ResponseTooLargeError(); + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; } + return new TextDecoder().decode(bytes); +} - return null; +async function requestUnpinnedText(params: { + url: string; + method: "GET" | "HEAD"; + userAgent: string; + maxBytes: number; + signal: AbortSignal; +}): Promise { + const response = await fetch(params.url, { + method: params.method, + headers: { + "User-Agent": params.userAgent, + Accept: "text/plain, application/yaml, application/x-yaml, */*;q=0.8", + "Cache-Control": "no-cache", + }, + redirect: "manual", + signal: params.signal, + }); + const headers = headersToRecord(response.headers); + if (response.status >= 300 && response.status < 400) { + await response.body?.cancel("redirect response is not consumed").catch(() => undefined); + return { status: response.status, headers, content: "" }; + } + const contentLength = Number(headers["content-length"] || "0"); + if (Number.isFinite(contentLength) && contentLength > params.maxBytes) { + await response.body?.cancel().catch(() => undefined); + throw new ResponseTooLargeError(); + } + const content = params.method === "HEAD" ? "" : await readFetchBodyLimited(response, params.maxBytes); + return { status: response.status, headers, content }; } -async function fetchTextDirect(request: SourceImportTransportRequest): Promise { +async function fetchTextDirect( + request: SourceImportTransportRequest, + allowUnsafeSubscriptionSources: boolean +): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), request.timeoutMs); try { let currentUrl = request.url; - let response: Response | null = null; + let response: DirectHttpResponse | null = null; for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) { - const guardFailure = await validatePublicFetchTarget(currentUrl); - if (guardFailure) return guardFailure; - - response = await fetch(currentUrl, { - method: request.purpose === "userinfo" ? "HEAD" : "GET", - headers: { - "User-Agent": request.userAgent, - Accept: "text/plain, application/yaml, application/x-yaml, */*;q=0.8", - "Cache-Control": "no-cache", - }, - redirect: "manual", - signal: controller.signal, - }); + const validation = await validatePublicFetchTarget(currentUrl, allowUnsafeSubscriptionSources); + if (!validation.ok) return validation.failure; + + const method = request.purpose === "userinfo" ? "HEAD" : "GET"; + response = validation.addresses + ? await requestPinnedText({ + url: currentUrl, + addresses: validation.addresses, + method, + userAgent: request.userAgent, + maxBytes: request.maxBytes, + signal: controller.signal, + }) + : await requestUnpinnedText({ + url: currentUrl, + method, + userAgent: request.userAgent, + maxBytes: request.maxBytes, + signal: controller.signal, + }); if (response.status < 300 || response.status >= 400) break; - const location = response.headers.get("location"); + const location = response.headers.location; if (!location) break; currentUrl = new URL(location, currentUrl).toString(); response = null; } if (!response) return toFailure("订阅重定向次数过多", 310); - const headers = headersToRecord(response.headers); - const contentLength = Number(headers["content-length"] || "0"); - if (Number.isFinite(contentLength) && contentLength > request.maxBytes) { - return toFailure("订阅响应过大", 413); - } - - const content = request.purpose === "userinfo" ? "" : await response.text(); - if (new TextEncoder().encode(content).length > request.maxBytes) { - return toFailure("订阅响应过大", 413); - } - if (!response.ok) { + const { headers, content } = response; + if (response.status < 200 || response.status >= 300) { return toFailure(`HTTP ${response.status}`, response.status); } @@ -160,6 +234,7 @@ async function fetchTextDirect(request: SourceImportTransportRequest): Promise { + const allowUnsafeSubscriptionSources = await getAllowUnsafeSubscriptionSources(); return importSubscriptionFromUrl(request, { timeoutMs: DEFAULT_TIMEOUT_MS, maxBytes: DEFAULT_MAX_BYTES, - fetchText: fetchTextDirect, + fetchText: (transportRequest) => fetchTextDirect(transportRequest, allowUnsafeSubscriptionSources), }); } @@ -180,12 +256,16 @@ export async function fetchSourceUserInfoHeadersDirect(source: { userinfoUserAgent?: string; }): Promise | undefined> { if (!source.userinfoUrl) return undefined; - const response = await fetchTextDirect({ - url: source.userinfoUrl, - userAgent: source.userinfoUserAgent?.trim() || SUBSCRIPTION_IMPORT_USER_AGENTS[0], - purpose: "userinfo", - timeoutMs: USERINFO_TIMEOUT_MS, - maxBytes: USERINFO_MAX_BYTES, - }); + const allowUnsafeSubscriptionSources = await getAllowUnsafeSubscriptionSources(); + const response = await fetchTextDirect( + { + url: source.userinfoUrl, + userAgent: source.userinfoUserAgent?.trim() || SUBSCRIPTION_IMPORT_USER_AGENTS[0], + purpose: "userinfo", + timeoutMs: USERINFO_TIMEOUT_MS, + maxBytes: USERINFO_MAX_BYTES, + }, + allowUnsafeSubscriptionSources + ); return response.ok ? response.headers : undefined; } diff --git a/local/src/lib/subscription-route-handlers.test.ts b/local/src/lib/subscription-route-handlers.test.ts index a5d0663..bfe88c7 100644 --- a/local/src/lib/subscription-route-handlers.test.ts +++ b/local/src/lib/subscription-route-handlers.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ deleteSubscription: vi.fn(), getSubscription: vi.fn(), json: vi.fn(), + jsonBodyError: vi.fn(), listSubscriptions: vi.fn(), readJsonBody: vi.fn(), refreshSubscription: vi.fn(), @@ -26,6 +27,8 @@ vi.mock("@local/lib/api-auth", () => ({ withCurrentAdmin: mocks.withCurrentAdmin vi.mock("@local/lib/http", () => ({ apiError: mocks.apiError, json: mocks.json, + jsonBodyError: mocks.jsonBodyError, + LOCAL_JSON_BODY_LIMITS: { subscription: 16 * 1024 * 1024 }, readJsonBody: mocks.readJsonBody, })); vi.mock("@local/lib/subscription-service", () => ({ @@ -47,6 +50,7 @@ describe("local subscription route handlers", () => { ); mocks.json.mockImplementation((body: unknown, status = 200) => ({ body, status })); mocks.apiError.mockImplementation((message: string, code: string, status: number) => ({ message, code, status })); + mocks.jsonBodyError.mockImplementation(() => ({ message: "Invalid JSON body.", code: "BAD_REQUEST", status: 400 })); }); it("extracts subscription ids from query strings", () => { @@ -71,14 +75,14 @@ describe("local subscription route handlers", () => { }); it("creates subscriptions and reports bad input", async () => { - mocks.readJsonBody.mockResolvedValueOnce(null); + mocks.readJsonBody.mockResolvedValueOnce({ ok: false, reason: "invalid_json" }); await expect(createSubscriptionResponse(request)).resolves.toEqual({ message: "Invalid JSON body.", code: "BAD_REQUEST", status: 400, }); - mocks.readJsonBody.mockResolvedValueOnce({ name: "A" }); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { name: "A" } }); mocks.createSubscription.mockResolvedValueOnce({ id: "sub-1" }); await expect(createSubscriptionResponse(request)).resolves.toEqual({ body: { subscription: { id: "sub-1" } }, @@ -86,7 +90,7 @@ describe("local subscription route handlers", () => { }); expect(mocks.createSubscription).toHaveBeenCalledWith("admin-1", { name: "A" }); - mocks.readJsonBody.mockResolvedValueOnce({ name: "" }); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { name: "" } }); mocks.createSubscription.mockRejectedValueOnce(new Error("Name required")); await expect(createSubscriptionResponse(request)).resolves.toEqual({ message: "Name required", @@ -94,7 +98,7 @@ describe("local subscription route handlers", () => { status: 400, }); - mocks.readJsonBody.mockResolvedValueOnce({ name: "" }); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { name: "" } }); mocks.createSubscription.mockRejectedValueOnce("bad"); await expect(createSubscriptionResponse(request)).resolves.toEqual({ message: "Unable to create subscription.", @@ -104,21 +108,21 @@ describe("local subscription route handlers", () => { }); it("updates subscriptions and validates JSON body shape", async () => { - mocks.readJsonBody.mockResolvedValueOnce([]); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: [] }); await expect(updateSubscriptionResponse(request, "sub-1")).resolves.toEqual({ message: "Invalid JSON body.", code: "BAD_REQUEST", status: 400, }); - mocks.readJsonBody.mockResolvedValueOnce({ name: "B" }); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { name: "B" } }); mocks.updateSubscription.mockResolvedValueOnce({ id: "sub-1", name: "B" }); await expect(updateSubscriptionResponse(request, "sub-1")).resolves.toEqual({ body: { subscription: { id: "sub-1", name: "B" } }, status: 200, }); - mocks.readJsonBody.mockResolvedValueOnce({ name: "B" }); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { name: "B" } }); mocks.updateSubscription.mockResolvedValueOnce(null); await expect(updateSubscriptionResponse(request, "missing")).resolves.toEqual({ message: "Subscription not found.", @@ -126,7 +130,7 @@ describe("local subscription route handlers", () => { status: 404, }); - mocks.readJsonBody.mockResolvedValueOnce({ name: "" }); + mocks.readJsonBody.mockResolvedValueOnce({ ok: true, value: { name: "" } }); mocks.updateSubscription.mockRejectedValueOnce("bad"); await expect(updateSubscriptionResponse(request, "sub-1")).resolves.toEqual({ message: "Unable to update subscription.", diff --git a/local/src/lib/subscription-route-handlers.ts b/local/src/lib/subscription-route-handlers.ts index 5957b95..fa5d076 100644 --- a/local/src/lib/subscription-route-handlers.ts +++ b/local/src/lib/subscription-route-handlers.ts @@ -1,5 +1,5 @@ import { withCurrentAdmin } from "@local/lib/api-auth"; -import { apiError, json, readJsonBody } from "@local/lib/http"; +import { apiError, json, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http"; import { createSubscription, deleteSubscription, @@ -19,11 +19,11 @@ export async function listSubscriptionsResponse() { export async function createSubscriptionResponse(request: Request) { return withCurrentAdmin(async (admin) => { - const body = await readJsonBody(request); - if (!body) return apiError("Invalid JSON body.", "BAD_REQUEST", 400); + const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.subscription); + if (!parsedBody.ok) return jsonBodyError(parsedBody); try { - const subscription = await createSubscription(admin.id, body); + const subscription = await createSubscription(admin.id, parsedBody.value); return json({ subscription }, 201); } catch (error) { return apiError(error instanceof Error ? error.message : "Unable to create subscription.", "BAD_REQUEST", 400); @@ -41,7 +41,9 @@ export async function getSubscriptionResponse(id: string) { export async function updateSubscriptionResponse(request: Request, id: string) { return withCurrentAdmin(async (admin) => { - const body = await readJsonBody(request); + const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.subscription); + if (!parsedBody.ok) return jsonBodyError(parsedBody); + const body = parsedBody.value; if (!body || typeof body !== "object" || Array.isArray(body)) { return apiError("Invalid JSON body.", "BAD_REQUEST", 400); } diff --git a/local/src/lib/subscription-service.test.ts b/local/src/lib/subscription-service.test.ts index 472b42a..4a37a0b 100644 --- a/local/src/lib/subscription-service.test.ts +++ b/local/src/lib/subscription-service.test.ts @@ -33,8 +33,10 @@ const mocks = vi.hoisted(() => ({ findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn(), + updateMany: vi.fn(), delete: vi.fn(), }, + subscriptionAutoUpdateState: { upsert: vi.fn() }, $transaction: vi.fn(), }, })); @@ -164,13 +166,16 @@ describe("local subscription service", () => { mocks.prisma.subscription.findFirst.mockResolvedValue(row()); mocks.prisma.subscription.findUnique.mockResolvedValue(row()); mocks.prisma.subscription.update.mockResolvedValue(row({ name: "Updated" })); + mocks.prisma.subscription.updateMany.mockResolvedValue({ count: 1 }); + mocks.prisma.subscriptionAutoUpdateState.upsert.mockResolvedValue({}); mocks.prisma.subscription.delete.mockResolvedValue(row()); - mocks.prisma.$transaction.mockImplementation(async (callback) => - callback({ - subscription: { update: vi.fn() }, - subscriptionAutoUpdateState: { upsert: vi.fn() }, - }) - ); + mocks.prisma.$transaction.mockImplementation(async (callback) => callback({ + subscription: { + update: mocks.prisma.subscription.update, + updateMany: mocks.prisma.subscription.updateMany, + }, + subscriptionAutoUpdateState: { upsert: mocks.prisma.subscriptionAutoUpdateState.upsert }, + })); mocks.importSourceUrlDirect.mockResolvedValue({ ok: true, parsedNodes: [node("Imported")], @@ -287,6 +292,7 @@ describe("local subscription service", () => { data: expect.objectContaining({ ownerId: "owner-1", name: "Created", + token: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), encryptedUrls: JSON.stringify(["https://example.com/sub"]), encryptedNodes: expect.stringContaining('"name":"Node"'), encryptedConfig: expect.stringContaining('"smartNodeMatchingEnabled":false'), @@ -335,6 +341,58 @@ describe("local subscription service", () => { autoUpdateInterval: 359, }) ).rejects.toThrow("自动更新最小间隔为 0.1 小时"); + + await expect(createSubscription("owner-1", { name: "Bad", nodes: [null] })).rejects.toThrow( + "节点 #1 必须是对象" + ); + await expect( + createSubscription("owner-1", { name: "Too many", nodes: Array.from({ length: 10_001 }, () => null) }) + ).rejects.toThrow("Node count cannot exceed 10000"); + }); + + it("validates node filters and permits provider-only output on create", async () => { + await expect( + createSubscription("owner-1", { + name: "Invalid filter", + nodes: [node("套餐到期提醒")], + config: { + nodeNameFilter: { enabled: true, excludeRegexes: ["("] }, + }, + }) + ).rejects.toThrow("节点名称过滤配置无效"); + + mocks.buildGenerateOptionsFromConfig.mockReturnValueOnce({ nodes: [] }); + await expect( + createSubscription("owner-1", { + name: "Empty filter result", + nodes: [node("套餐到期提醒")], + config: { + nodeNameFilter: { enabled: true, excludeRegexes: ["套餐到期"] }, + }, + }) + ).rejects.toThrow("过滤后没有可用节点"); + + mocks.buildGenerateOptionsFromConfig.mockReturnValueOnce({ + nodes: [], + proxyProviders: { provider: { type: "http" } }, + }); + await expect( + createSubscription("owner-1", { + name: "Provider output", + nodes: [node("套餐到期提醒")], + config: { + sources: [ + { + id: "provider", + type: "url", + content: "https://provider.example/sub", + useProxyProviders: true, + }, + ], + nodeNameFilter: { enabled: true, excludeRegexes: ["套餐到期"] }, + }, + }) + ).resolves.toMatchObject({ name: "Created" }); }); it("updates subscriptions and preserves existing values when fields are omitted", async () => { @@ -387,6 +445,60 @@ describe("local subscription service", () => { }), include: { autoUpdateState: true }, }); + + mocks.prisma.subscription.findFirst.mockResolvedValueOnce(row({ autoUpdateInterval: null })); + await updateSubscription("owner-1", "sub-1", { autoUpdateInterval: 7200 }); + expect(mocks.prisma.subscriptionAutoUpdateState.upsert).toHaveBeenCalledWith({ + where: { subscriptionId: "sub-1" }, + create: { subscriptionId: "sub-1" }, + update: { + externalFailureCount: 0, + failureSourceState: null, + lastFailedAt: null, + lastAttemptedAt: null, + disabledAt: null, + disabledReason: null, + disabledPreviousInterval: null, + }, + }); + }); + + it("rejects updates whose filter removes every ordinary node", async () => { + mocks.buildGenerateOptionsFromConfig.mockReturnValueOnce({ nodes: [] }); + + await expect( + updateSubscription("owner-1", "sub-1", { + nodes: [node("套餐到期提醒")], + config: { + nodeNameFilter: { enabled: true, excludeRegexes: ["套餐到期"] }, + }, + }) + ).rejects.toThrow("过滤后没有可用节点"); + expect(mocks.prisma.subscription.update).not.toHaveBeenCalled(); + }); + + it("replaces submitted config instead of retaining omitted stale fields", async () => { + mocks.prisma.subscription.findFirst.mockResolvedValueOnce( + row({ + encryptedConfig: JSON.stringify({ + staleSetting: true, + sources: [{ id: "old", type: "url", content: "https://old.example/sub" }], + }), + }) + ); + + await updateSubscription("owner-1", "sub-1", { + config: { + freshSetting: true, + sources: [{ id: "new", type: "url", content: "https://new.example/sub" }], + }, + }); + + const update = mocks.prisma.subscription.update.mock.calls.at(-1)?.[0]; + const encryptedConfig = update?.data?.encryptedConfig; + expect(typeof encryptedConfig).toBe("string"); + expect(JSON.parse(encryptedConfig)).toMatchObject({ freshSetting: true }); + expect(JSON.parse(encryptedConfig)).not.toHaveProperty("staleSetting"); }); it("builds fetch callbacks for refresh source imports", async () => { @@ -453,6 +565,18 @@ describe("local subscription service", () => { }); expect(mocks.prisma.$transaction).toHaveBeenCalled(); + mocks.prisma.subscription.updateMany.mockResolvedValueOnce({ count: 0 }); + await expect(refreshSubscription("owner-1", "sub-1")).resolves.toEqual({ + ok: false, + response: { + body: { + error: "Subscription changed while refresh was in progress.", + code: "SUBSCRIPTION_CHANGED", + }, + status: 409, + }, + }); + mocks.prepareRefreshCacheResult.mockReturnValueOnce({ ok: false, reason: "too_many_nodes" }); await expect(refreshSubscription("owner-1", "sub-1")).resolves.toEqual({ ok: false, diff --git a/local/src/lib/subscription-service.ts b/local/src/lib/subscription-service.ts index 3850876..401e0d4 100644 --- a/local/src/lib/subscription-service.ts +++ b/local/src/lib/subscription-service.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes, 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"; @@ -10,12 +10,12 @@ import { normalizeSubscriptionConfigForPersistence, normalizeSubscriptionInfoForPersistence, normalizeSubscriptionName, - normalizeSubscriptionNodeList, normalizeSubscriptionUrlList, prepareRefreshCacheResult, refreshNodeSnapshot, serializeSubscriptionDetailData, serializeSubscriptionSummaryData, + validateSubscriptionNodeList, type SavedSource, type RefreshNodeSnapshotResult, } from "@subboost/server-core/subscription"; @@ -101,6 +101,14 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function validateLocalSubscriptionNodes(value: unknown): ParsedNode[] { + if (value === undefined || value === null) return []; + if (Array.isArray(value) && value.length > MAX_NODES_PER_SUBSCRIPTION) { + throw new Error(`Node count cannot exceed ${MAX_NODES_PER_SUBSCRIPTION}.`); + } + return validateSubscriptionNodeList(value); +} + function buildLocalSubscriptionUrl(token: string): string { return `${getAppUrl()}/api/subscriptions/${token}/config.yaml`; } @@ -118,11 +126,30 @@ function buildLocalSubscriptionConfig( existingConfig, idFactory: randomUUID, splitUrlLines: true, + mergeExistingConfig: false, defaultSmartNodeMatchingEnabled: true, } ); } +function assertNodeNameFilterKeepsOutput( + nodes: ParsedNode[], + config: Record +): void { + if (nodes.length === 0) return; + const options = buildGenerateOptionsFromConfig(config, { nodes }); + const hasProxyProviders = Boolean( + options.proxyProviders && Object.keys(options.proxyProviders).length > 0 + ); + if (options.nodes.length === 0 && !hasProxyProviders) { + throw new Error("过滤后没有可用节点"); + } +} + +export function generateLocalSubscriptionToken(): string { + return randomBytes(32).toString("base64url"); +} + export function readSubscriptionSecrets(row: SubscriptionRow) { return { urls: decryptJson(row.encryptedUrls, []), @@ -176,10 +203,11 @@ export async function createSubscription(ownerId: string, body: unknown): Promis if (!name) throw new Error("Subscription name is required."); const urls = normalizeSubscriptionUrlList(body.urls); - const nodes = normalizeSubscriptionNodeList(body.nodes); + const nodes = validateLocalSubscriptionNodes(body.nodes); if (urls.length === 0 && nodes.length === 0) throw new Error("At least one URL or node is required."); const config = buildLocalSubscriptionConfig(body); + assertNodeNameFilterKeepsOutput(nodes, config); const autoUpdateInterval = normalizeLocalAutoUpdateIntervalSeconds(body.autoUpdateInterval); const subscriptionInfo = normalizeSubscriptionInfoForPersistence(body.subscriptionInfo) ?? {}; @@ -187,6 +215,7 @@ export async function createSubscription(ownerId: string, body: unknown): Promis data: { ownerId, name, + token: generateLocalSubscriptionToken(), encryptedUrls: encryptJson(urls), encryptedNodes: encryptJson(nodes), encryptedConfig: encryptJson(config), @@ -209,16 +238,18 @@ export async function updateSubscription(ownerId: string, id: string, body: unkn const hasUrls = "urls" in body; const hasNodes = "nodes" in body; const hasConfig = "config" in body || "smartNodeMatchingEnabled" in body; + const nextNodes = hasNodes ? validateLocalSubscriptionNodes(body.nodes) : currentSecrets.nodes; + let nextConfig = currentSecrets.config; if (hasUrls) { data.encryptedUrls = encryptJson(normalizeSubscriptionUrlList(body.urls)); } if (hasNodes) { - data.encryptedNodes = encryptJson(normalizeSubscriptionNodeList(body.nodes)); + data.encryptedNodes = encryptJson(nextNodes); } if (hasConfig) { - const config = buildLocalSubscriptionConfig(body, currentSecrets.config); - data.encryptedConfig = encryptJson(config); + nextConfig = buildLocalSubscriptionConfig(body, currentSecrets.config); + data.encryptedConfig = encryptJson(nextConfig); } if ("subscriptionInfo" in body) { data.encryptedSubscriptionInfo = encryptJson(normalizeSubscriptionInfoForPersistence(body.subscriptionInfo) ?? {}); @@ -226,20 +257,40 @@ export async function updateSubscription(ownerId: string, id: string, body: unkn if (hasUrls || hasNodes || hasConfig) { const nextUrls = hasUrls ? normalizeSubscriptionUrlList(body.urls) : currentSecrets.urls; - const nextNodes = hasNodes ? normalizeSubscriptionNodeList(body.nodes) : currentSecrets.nodes; if (nextUrls.length === 0 && nextNodes.length === 0) { throw new Error("At least one URL or node is required."); } + assertNodeNameFilterKeepsOutput(nextNodes, nextConfig); } + let resetAutoUpdateState = false; if ("autoUpdateInterval" in body) { - data.autoUpdateInterval = normalizeLocalAutoUpdateIntervalSeconds(body.autoUpdateInterval); + const nextAutoUpdateInterval = normalizeLocalAutoUpdateIntervalSeconds(body.autoUpdateInterval); + data.autoUpdateInterval = nextAutoUpdateInterval; + resetAutoUpdateState = current.autoUpdateInterval === null && nextAutoUpdateInterval !== null; } - const row = await prisma.subscription.update({ - where: { id: current.id }, - data, - include: { autoUpdateState: true }, + const row = await prisma.$transaction(async (tx) => { + if (resetAutoUpdateState) { + await tx.subscriptionAutoUpdateState.upsert({ + where: { subscriptionId: current.id }, + create: { subscriptionId: current.id }, + update: { + externalFailureCount: 0, + failureSourceState: null, + lastFailedAt: null, + lastAttemptedAt: null, + disabledAt: null, + disabledReason: null, + disabledPreviousInterval: null, + }, + }); + } + return tx.subscription.update({ + where: { id: current.id }, + data, + include: { autoUpdateState: true }, + }); }); return formatSubscription(row); } @@ -296,21 +347,24 @@ export function buildSubscriptionCacheExpiry(from: Date): Date { async function persistRefreshSuccess(params: { subscriptionId: string; + expectedUpdatedAt: Date; snapshot: RefreshNodeSnapshotResult; config: Record; cachedAt: Date; -}) { - await prisma.$transaction(async (tx) => { - await tx.subscription.update({ - where: { id: params.subscriptionId }, +}): Promise { + return prisma.$transaction(async (tx) => { + const updated = await tx.subscription.updateMany({ + where: { id: params.subscriptionId, updatedAt: params.expectedUpdatedAt }, data: { encryptedNodes: encryptJson(params.snapshot.nodes), encryptedConfig: encryptJson({ ...params.config, sources: params.snapshot.savedSources }), encryptedSubscriptionInfo: encryptJson(params.snapshot.subscriptionInfo), lastUpdatedAt: params.cachedAt, cacheExpiresAt: buildSubscriptionCacheExpiry(params.cachedAt), + updatedAt: params.cachedAt, }, }); + if (updated.count !== 1) return false; await tx.subscriptionAutoUpdateState.upsert({ where: { subscriptionId: params.subscriptionId }, create: { subscriptionId: params.subscriptionId }, @@ -324,6 +378,7 @@ async function persistRefreshSuccess(params: { disabledPreviousInterval: null, }, }); + return true; }); } @@ -355,7 +410,22 @@ export async function refreshSubscription(ownerId: string, id: string) { } const cachedAt = new Date(); - await persistRefreshSuccess({ subscriptionId: row.id, snapshot, config: secrets.config, cachedAt }); + const persisted = await persistRefreshSuccess({ + subscriptionId: row.id, + expectedUpdatedAt: row.updatedAt, + snapshot, + config: secrets.config, + cachedAt, + }); + if (!persisted) { + return { + ok: false as const, + response: { + body: { error: "Subscription changed while refresh was in progress.", code: "SUBSCRIPTION_CHANGED" }, + status: 409, + }, + }; + } return { ok: true as const, body: buildManualRefreshSuccessResponseBody({ diff --git a/local/test/auto-update-service.test.ts b/local/test/auto-update-service.test.ts index 00cca57..6a7d3fe 100644 --- a/local/test/auto-update-service.test.ts +++ b/local/test/auto-update-service.test.ts @@ -3,6 +3,13 @@ import { refreshNodeSnapshot } from "@subboost/server-core/subscription"; import { prisma } from "@local/lib/prisma"; import { runLocalSubscriptionAutoUpdateCron } from "@local/lib/auto-update-service"; +const dbMocks = vi.hoisted(() => ({ + findMany: vi.fn(), + updateMany: vi.fn(async () => ({ count: 1 })), + upsert: vi.fn(async (args) => args), + transaction: vi.fn(), +})); + vi.mock("@local/lib/crypto", () => ({ decryptJson: vi.fn((ciphertext: string | null | undefined, fallback: unknown) => ciphertext ? JSON.parse(ciphertext.replace(/^json:/, "")) : fallback @@ -16,13 +23,13 @@ vi.mock("@local/lib/crypto", () => ({ vi.mock("@local/lib/prisma", () => ({ prisma: { subscription: { - findMany: vi.fn(), - update: vi.fn(async (args) => args), + findMany: dbMocks.findMany, + updateMany: dbMocks.updateMany, }, subscriptionAutoUpdateState: { - upsert: vi.fn(async (args) => args), + upsert: dbMocks.upsert, }, - $transaction: vi.fn(async (operations: Array>) => Promise.all(operations)), + $transaction: dbMocks.transaction, }, })); @@ -67,9 +74,14 @@ function subscription(overrides: Record = {}) { beforeEach(() => { vi.mocked(prisma.subscription.findMany).mockReset(); - vi.mocked(prisma.subscription.update).mockClear(); + vi.mocked(prisma.subscription.updateMany).mockClear(); vi.mocked(prisma.subscriptionAutoUpdateState.upsert).mockClear(); vi.mocked(prisma.$transaction).mockClear(); + dbMocks.updateMany.mockResolvedValue({ count: 1 }); + dbMocks.transaction.mockImplementation(async (callback) => callback({ + subscription: { updateMany: dbMocks.updateMany }, + subscriptionAutoUpdateState: { upsert: dbMocks.upsert }, + })); vi.mocked(refreshNodeSnapshot).mockReset(); }); @@ -82,7 +94,7 @@ describe("local subscription auto-update service", () => { const summary = await runLocalSubscriptionAutoUpdateCron(new Date("2026-06-02T00:30:00.000Z")); expect(summary.results).toMatchObject({ total: 1, updated: 0, skipped: 1, failed: 0 }); expect(refreshNodeSnapshot).not.toHaveBeenCalled(); - expect(prisma.subscription.update).not.toHaveBeenCalled(); + expect(prisma.subscription.updateMany).not.toHaveBeenCalled(); }); it("refreshes due subscriptions and persists the refreshed cache", async () => { @@ -110,9 +122,9 @@ describe("local subscription auto-update service", () => { storedNodes: [], }) ); - expect(prisma.subscription.update).toHaveBeenCalledWith( + expect(prisma.subscription.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "sub-1" }, + where: { id: "sub-1", updatedAt: new Date("2026-06-01T00:00:00.000Z") }, data: expect.objectContaining({ encryptedNodes: expect.stringContaining("node-a"), encryptedConfig: expect.stringContaining("src-1"), @@ -132,4 +144,3 @@ describe("local subscription auto-update service", () => { ); }); }); - diff --git a/local/test/cron-route-contract.test.ts b/local/test/cron-route-contract.test.ts index 34a9c76..9fc89f7 100644 --- a/local/test/cron-route-contract.test.ts +++ b/local/test/cron-route-contract.test.ts @@ -5,6 +5,14 @@ import { refreshRuleIndex } from "@local/lib/rule-catalog"; import * as updateSubscriptionsRoute from "../app/api/cron/update-subscriptions/route"; import * as updateRuleIndexRoute from "../app/api/cron/update-rule-index/route"; +const leaseMocks = vi.hoisted(() => ({ + acquire: vi.fn(), + assertOwned: vi.fn(), + release: vi.fn(), + stop: vi.fn(), + JobLeaseLostError: class JobLeaseLostError extends Error {}, +})); + vi.mock("@local/lib/auto-update-service", () => ({ runLocalSubscriptionAutoUpdateCron: vi.fn(), })); @@ -13,6 +21,16 @@ vi.mock("@local/lib/rule-catalog", () => ({ refreshRuleIndex: vi.fn(), })); +vi.mock("@local/lib/job-lease", () => ({ + acquireLocalJobLease: leaseMocks.acquire, + releaseLocalJobLease: leaseMocks.release, + startLocalJobLeaseHeartbeat: vi.fn(() => ({ + assertOwned: leaseMocks.assertOwned, + stop: leaseMocks.stop, + })), + JobLeaseLostError: leaseMocks.JobLeaseLostError, +})); + function cronRequest(secret?: string): Request { return new Request("http://local.test/api/cron/update-subscriptions", { method: "POST", @@ -35,6 +53,14 @@ beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("CRON_SECRET", "secret-1"); + leaseMocks.acquire.mockResolvedValue({ + name: "local-subscription-auto-update", + ownerToken: "owner", + expiresAt: new Date("2026-07-16T00:05:00.000Z"), + }); + leaseMocks.assertOwned.mockResolvedValue(undefined); + leaseMocks.release.mockResolvedValue(undefined); + leaseMocks.stop.mockResolvedValue(undefined); vi.mocked(runLocalSubscriptionAutoUpdateCron).mockResolvedValue({ results: { total: 0, updated: 0, skipped: 0, failed: 0, errors: [] }, updatedSubscriptions: [], @@ -62,7 +88,7 @@ describe("local cron routes", () => { it("rejects cron calls when CRON_SECRET is missing in production", async () => { vi.stubEnv("CRON_SECRET", ""); const response = await updateSubscriptionsRoute.POST(cronRequest()); - expect(response.status).toBe(500); + expect(response.status).toBe(503); expect(await readJson(response)).toEqual({ error: "CRON_SECRET not configured.", code: "CONFIGURATION_ERROR", @@ -108,6 +134,23 @@ describe("local cron routes", () => { expect((await readJson(response)).success).toBe(true); }); + it("skips an overlapping run and fails closed after lease loss", async () => { + leaseMocks.acquire.mockResolvedValueOnce(null); + let response = await updateSubscriptionsRoute.POST(cronRequest("secret-1")); + expect(response.status).toBe(200); + expect(await readJson(response)).toMatchObject({ skipped: true, reason: "already_running" }); + expect(runLocalSubscriptionAutoUpdateCron).not.toHaveBeenCalled(); + + vi.mocked(runLocalSubscriptionAutoUpdateCron).mockRejectedValueOnce(new leaseMocks.JobLeaseLostError()); + response = await updateSubscriptionsRoute.POST(cronRequest("secret-1")); + expect(response.status).toBe(503); + expect(await readJson(response)).toEqual({ + error: "Local subscription update lease lost.", + code: "JOB_LEASE_LOST", + }); + expect(leaseMocks.release).toHaveBeenCalled(); + }); + it("runs the rule index refresh cron when authorized", async () => { const response = await updateRuleIndexRoute.POST( new Request("http://local.test/api/cron/update-rule-index", { @@ -120,4 +163,3 @@ describe("local cron routes", () => { expect((await readJson(response)).success).toBe(true); }); }); - diff --git a/local/test/source-import-guard.test.ts b/local/test/source-import-guard.test.ts index 70860c7..dbdec80 100644 --- a/local/test/source-import-guard.test.ts +++ b/local/test/source-import-guard.test.ts @@ -1,4 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@local/lib/source-import-settings", () => ({ + getAllowUnsafeSubscriptionSources: vi.fn(async () => false), +})); + import { importSourceUrlDirect } from "@local/lib/source-import"; beforeEach(() => { @@ -19,4 +24,3 @@ describe("local source import network guard", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); - diff --git a/local/test/source-import-route-contract.test.ts b/local/test/source-import-route-contract.test.ts index 9045d18..5044298 100644 --- a/local/test/source-import-route-contract.test.ts +++ b/local/test/source-import-route-contract.test.ts @@ -80,6 +80,19 @@ describe("local source import route", () => { expect(importSourceUrlDirect).not.toHaveBeenCalled(); }); + it("rejects source import bodies above 64 KiB", async () => { + const response = await route.POST(new Request("http://local.test/api/source-import", { + method: "POST", + headers: { "content-length": String(64 * 1024 + 1) }, + body: "{}", + })); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: "Request body is too large.", + code: "PAYLOAD_TOO_LARGE", + }); + }); + it("normalizes optional userinfo fields and forwards import errors", async () => { let response = await route.POST(jsonRequest({ url: " https://example.com/sub.yaml ", diff --git a/local/test/subscription-route-contract.test.ts b/local/test/subscription-route-contract.test.ts index 0ef551e..cd9edb3 100644 --- a/local/test/subscription-route-contract.test.ts +++ b/local/test/subscription-route-contract.test.ts @@ -125,6 +125,20 @@ describe("local subscription routes", () => { expect(createSubscription).toHaveBeenCalledWith("admin-1", fullConfigPayload); }); + it("rejects subscription bodies above 16 MiB", async () => { + const response = await pluralCollectionRoute.POST(new Request("http://local.test/api/subscriptions", { + method: "POST", + headers: { "content-length": String(16 * 1024 * 1024 + 1) }, + body: "{}", + })); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: "Request body is too large.", + code: "PAYLOAD_TOO_LARGE", + }); + expect(createSubscription).not.toHaveBeenCalled(); + }); + it("uses plural item and refresh routes with path ids", async () => { const params = { params: Promise.resolve({ id: "sub-1" }) }; diff --git a/local/test/template-route-contract.test.ts b/local/test/template-route-contract.test.ts index 38d5477..815c7c8 100644 --- a/local/test/template-route-contract.test.ts +++ b/local/test/template-route-contract.test.ts @@ -94,6 +94,19 @@ describe("local template routes", () => { expect(deleteTemplate).toHaveBeenCalledWith("admin-1", "tpl-1"); }); + it("rejects template bodies above 4 MiB", async () => { + const response = await collectionRoute.POST(new Request("http://local.test/api/templates", { + method: "POST", + headers: { "content-length": String(4 * 1024 * 1024 + 1) }, + body: "{}", + })); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: "Request body is too large.", + code: "PAYLOAD_TOO_LARGE", + }); + }); + it("reports local template collection errors", async () => { vi.mocked(listTemplates).mockRejectedValueOnce(new Error("Bad filters")); let response = await collectionRoute.GET(new Request("http://local.test/api/templates?type=default")); diff --git a/package-lock.json b/package-lock.json index b2793aa..645671f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,8 @@ "local" ], "dependencies": { - "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", + "@prisma/adapter-pg": "^7.9.0", + "@prisma/client": "^7.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -23,7 +22,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -37,9 +35,9 @@ "clsx": "^2.1.0", "dotenv": "^17.4.2", "jose": "^6.2.3", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.0", "lucide-react": "^1.17.0", - "next": "^16.2.7", + "next": "^16.2.12", "peggy": "^5.1.0", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -54,9 +52,9 @@ "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", - "eslint-config-next": "^16.2.7", - "postcss": "8.5.15", - "prisma": "^7.8.0", + "eslint-config-next": "^16.2.12", + "postcss": "8.5.23", + "prisma": "^7.9.0", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "vitest": "^4.1.7" @@ -70,9 +68,8 @@ "version": "2.6.0", "license": "AGPL-3.0-only", "dependencies": { - "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", + "@prisma/adapter-pg": "^7.9.0", + "@prisma/client": "^7.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -80,7 +77,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -94,10 +90,11 @@ "clsx": "^2.1.0", "dotenv": "^17.4.2", "jose": "^6.2.3", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.0", "lucide-react": "^1.17.0", - "next": "^16.2.7", + "next": "^16.2.12", "peggy": "^5.1.0", + "prisma": "^7.9.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", @@ -111,9 +108,8 @@ "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", - "eslint-config-next": "^16.2.7", - "postcss": "8.5.15", - "prisma": "^7.8.0", + "eslint-config-next": "^16.2.12", + "postcss": "8.5.23", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" }, @@ -375,33 +371,30 @@ } }, "node_modules/@electric-sql/pglite": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", - "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", - "devOptional": true, + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", + "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", - "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", - "devOptional": true, + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", + "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" }, "peerDependencies": { - "@electric-sql/pglite": "0.4.1" + "@electric-sql/pglite": "0.4.3" } }, "node_modules/@electric-sql/pglite-tools": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", - "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", - "devOptional": true, + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", + "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", "license": "Apache-2.0", "peerDependencies": { - "@electric-sql/pglite": "0.4.1" + "@electric-sql/pglite": "0.4.3" } }, "node_modules/@emnapi/core": { @@ -420,6 +413,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -619,19 +613,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -709,9 +690,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -721,19 +702,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -743,19 +724,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -769,9 +769,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -785,9 +785,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -801,9 +801,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -817,9 +817,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -833,9 +833,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -849,9 +849,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -865,9 +865,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -881,9 +881,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -897,9 +897,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -913,9 +913,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -925,19 +925,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -947,19 +947,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -969,19 +969,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -991,19 +991,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1013,19 +1013,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1035,19 +1035,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1057,19 +1057,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1079,38 +1079,64 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1120,16 +1146,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1139,16 +1165,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1158,7 +1184,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1214,13 +1240,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "devOptional": true, - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1241,15 +1260,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.7.tgz", - "integrity": "sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", + "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.7.tgz", - "integrity": "sha512-VbS+QgMHqvIDMTIqD2xMBKK1otIpdAUKA8VLHFwR9h6OfU/mOm7w/69nQcvdmI8hCk99Wr2AsGLn/PJ/tMHw1w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", + "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", "dev": true, "license": "MIT", "dependencies": { @@ -1257,9 +1276,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.7.tgz", - "integrity": "sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", + "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", "cpu": [ "arm64" ], @@ -1273,9 +1292,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.7.tgz", - "integrity": "sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", + "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", "cpu": [ "x64" ], @@ -1289,9 +1308,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.7.tgz", - "integrity": "sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", + "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", "cpu": [ "arm64" ], @@ -1305,9 +1324,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.7.tgz", - "integrity": "sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", + "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", "cpu": [ "arm64" ], @@ -1321,9 +1340,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.7.tgz", - "integrity": "sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", + "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", "cpu": [ "x64" ], @@ -1337,9 +1356,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.7.tgz", - "integrity": "sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", + "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", "cpu": [ "x64" ], @@ -1353,9 +1372,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.7.tgz", - "integrity": "sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", + "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", "cpu": [ "arm64" ], @@ -1369,9 +1388,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.7.tgz", - "integrity": "sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", + "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", "cpu": [ "x64" ], @@ -1467,24 +1486,24 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", - "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.0.tgz", + "integrity": "sha512-kPYuFvNTlqnaFf2UpXBBG3ycTT3PL76uSZtLFBEwDytjMMUW8ZHrsb9cSNIarzdPW5EXWmBOeOq9/MVjMtbWkA==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.8.0", + "@prisma/driver-adapter-utils": "7.9.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "node_modules/@prisma/client": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", - "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.0.tgz", + "integrity": "sha512-BTG/mB+WL/1sD2gWwdNc2uuVJjNNBgCDlPFdjco6jJArgbg4IAChtzVeW4debFa/NKBbsGedCjET316sjllWTQ==", "license": "Apache-2.0", "dependencies": { - "@prisma/client-runtime-utils": "7.8.0" + "@prisma/client-runtime-utils": "7.9.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0" @@ -1503,16 +1522,15 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", - "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.0.tgz", + "integrity": "sha512-kMVmS4ZEy3xlkca+TfxOEm/ToVVlOS2x1Tc6/wIRf/HfczBqENtSPcKszy4ZpFNzjJ8SRKvlU5V0rrpoFw2KOg==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", - "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", - "devOptional": true, + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.9.0.tgz", + "integrity": "sha512-CsoK2mhl0u+N4/8V+XroQMOUNIic4isqD+E2HBG8l1yGEKo62CFDu3FHo0FdwItjl6XkW+omA1STSzeN1DAXlg==", "license": "Apache-2.0", "dependencies": { "c12": "3.3.4", @@ -1522,29 +1540,26 @@ } }, "node_modules/@prisma/debug": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", - "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.0.tgz", + "integrity": "sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", - "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", - "devOptional": true, + "version": "0.24.14", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.14.tgz", + "integrity": "sha512-NhFO49O2JPTdzYiLHvceQn/HiwmcKF/iGV39ko3CpYsoGqS3rz3ko6gzuxFSIeHNwNJeuNcDexyyGeTO3DW80A==", "license": "ISC", "dependencies": { - "@electric-sql/pglite": "0.4.1", - "@electric-sql/pglite-socket": "0.1.1", - "@electric-sql/pglite-tools": "0.3.1", - "@hono/node-server": "1.19.11", + "@electric-sql/pglite": "0.4.3", + "@electric-sql/pglite-socket": "0.1.3", + "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", - "@prisma/streams-local": "0.1.2", + "@prisma/streams-local": "0.1.11", + "find-my-way": "9.6.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", - "hono": "^4.12.8", - "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", @@ -1554,72 +1569,66 @@ } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", - "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.0.tgz", + "integrity": "sha512-fFXujitfMyjk3kOd1Tbs5FXBm6i2OWwEhaP5lHgkUM99jHpPEQwCWj+z/WKPFq6EDMThE1zGzSlVegtR0Pmu2w==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.9.0" } }, "node_modules/@prisma/engines": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", - "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", - "devOptional": true, + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.9.0.tgz", + "integrity": "sha512-lDWJp/pgSWCLfYsupmmNo96jfsbQnH1yjia8XVM2Kh8nRZhD0bQU2jCHuy3ZTPMLR3apRD3k145ybENalAYjYw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0", - "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "@prisma/fetch-engine": "7.8.0", - "@prisma/get-platform": "7.8.0" + "@prisma/debug": "7.9.0", + "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "@prisma/fetch-engine": "7.9.0", + "@prisma/get-platform": "7.9.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", - "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", - "devOptional": true, + "version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad.tgz", + "integrity": "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==", "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", - "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", - "devOptional": true, + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.0.tgz", + "integrity": "sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.9.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", - "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", - "devOptional": true, + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.9.0.tgz", + "integrity": "sha512-F0XlIgjbE3EywRVR/HpCerNI/dxo40vK66tHcWpsWYwH/Jk9+FsICEzATeMsZ7bdnpZz93hkD4sAb5rKLsCCpA==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0", - "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "@prisma/get-platform": "7.8.0" + "@prisma/debug": "7.9.0", + "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "@prisma/get-platform": "7.9.0" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", - "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", - "devOptional": true, + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.0.tgz", + "integrity": "sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.9.0" } }, "node_modules/@prisma/get-platform": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -1629,21 +1638,18 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", - "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", - "devOptional": true, + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", + "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -1652,7 +1658,7 @@ "proper-lockfile": "^4.1.2" }, "engines": { - "bun": ">=1.3.6", + "bun": ">=1.2.0", "node": ">=22.0.0" } }, @@ -1660,7 +1666,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1677,18 +1682,25 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, "license": "MIT" }, "node_modules/@prisma/studio-core": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", - "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", - "devOptional": true, + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", + "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", - "chart.js": "4.5.1" + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", + "elkjs": "0.11.1" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0", @@ -1712,37 +1724,6 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1831,36 +1812,6 @@ } } }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -2521,52 +2472,6 @@ } } }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -2682,7 +2587,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -3162,7 +3066,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@subboost/config": { @@ -3487,6 +3390,84 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3501,6 +3482,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -3522,6 +3509,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", @@ -3546,7 +3539,6 @@ "version": "19.2.16", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4156,19 +4148,160 @@ "win32" ] }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", - "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } }, "node_modules/@vitest/expect": { "version": "4.1.8", @@ -4574,7 +4707,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -4629,10 +4761,9 @@ } }, "node_modules/better-result": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", - "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", - "devOptional": true, + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", "license": "MIT" }, "node_modules/brace-expansion": { @@ -4697,7 +4828,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -4829,24 +4959,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -4870,6 +4986,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4925,7 +5047,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -4939,7 +5060,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4954,9 +5074,135 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5047,7 +5293,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -5093,14 +5338,21 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -5110,7 +5362,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -5173,7 +5424,6 @@ "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -5187,6 +5437,12 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -5198,7 +5454,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -5222,7 +5477,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -5499,13 +5753,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.7.tgz", - "integrity": "sha512-CQ2aNXkrsjaGA2oJBE1LYnlRdphIAQE9ZQfX9hSv1PNGPyiOMSaVeBfTIO29QxYz+ij/hZudK0cfpCG1HXWstg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", + "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.7", + "@next/eslint-plugin-next": "16.2.12", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -5865,17 +6119,15 @@ } }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -5894,11 +6146,16 @@ "node": ">=8.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5945,11 +6202,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "devOptional": true, + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -5998,6 +6263,20 @@ "node": ">=8" } }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6056,7 +6335,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -6129,7 +6407,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -6193,7 +6470,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { @@ -6242,10 +6518,9 @@ } }, "node_modules/giget": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", - "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", - "devOptional": true, + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", "license": "MIT", "bin": { "giget": "dist/cli.mjs" @@ -6311,21 +6586,18 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/grammex": { - "version": "3.1.12", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", - "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", - "devOptional": true, + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", + "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", - "devOptional": true, "license": "MIT" }, "node_modules/has-bigints": { @@ -6439,28 +6711,10 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-status-codes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "devOptional": true, - "license": "MIT" - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6525,6 +6779,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6799,7 +7062,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -6958,7 +7220,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -6983,7 +7244,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7006,9 +7266,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "funding": [ { "type": "github", @@ -7411,6 +7671,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7422,7 +7688,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -7452,7 +7717,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", - "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -7551,7 +7815,6 @@ "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "devOptional": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -7572,7 +7835,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -7582,9 +7844,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -7623,12 +7885,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.7.tgz", - "integrity": "sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", + "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.7", + "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -7642,14 +7904,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.7", - "@next/swc-darwin-x64": "16.2.7", - "@next/swc-linux-arm64-gnu": "16.2.7", - "@next/swc-linux-arm64-musl": "16.2.7", - "@next/swc-linux-x64-gnu": "16.2.7", - "@next/swc-linux-x64-musl": "16.2.7", - "@next/swc-win32-arm64-msvc": "16.2.7", - "@next/swc-win32-x64-msvc": "16.2.7", + "@next/swc-darwin-arm64": "16.2.12", + "@next/swc-darwin-x64": "16.2.12", + "@next/swc-linux-arm64-gnu": "16.2.12", + "@next/swc-linux-arm64-musl": "16.2.12", + "@next/swc-linux-x64-gnu": "16.2.12", + "@next/swc-linux-x64-musl": "16.2.12", + "@next/swc-win32-arm64-msvc": "16.2.12", + "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { @@ -7842,7 +8104,6 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, "license": "MIT" }, "node_modules/optionator": { @@ -7940,7 +8201,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7957,7 +8217,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/peggy": { @@ -7981,7 +8240,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "devOptional": true, "license": "MIT" }, "node_modules/pg": { @@ -8105,7 +8363,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -8124,9 +8381,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -8143,7 +8400,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8155,7 +8412,6 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -8215,17 +8471,16 @@ } }, "node_modules/prisma": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", - "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", - "devOptional": true, + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.0.tgz", + "integrity": "sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "7.8.0", - "@prisma/dev": "0.24.3", - "@prisma/engines": "7.8.0", - "@prisma/studio-core": "0.27.3", + "@prisma/config": "7.9.0", + "@prisma/dev": "0.24.14", + "@prisma/engines": "7.9.0", + "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, @@ -8264,7 +8519,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8276,7 +8530,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, "license": "ISC" }, "node_modules/punycode": { @@ -8293,7 +8546,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -8331,7 +8583,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.6", @@ -8439,7 +8690,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -8497,7 +8747,6 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -8507,7 +8756,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8557,11 +8805,19 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -8578,6 +8834,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -8691,11 +8953,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -8717,8 +9000,7 @@ "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -8770,54 +9052,59 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -8831,7 +9118,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8844,7 +9130,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8937,7 +9222,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -8977,7 +9261,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9001,7 +9284,6 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -9653,10 +9935,9 @@ } }, "node_modules/valibot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", - "devOptional": true, + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -9872,7 +10153,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10033,7 +10313,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", @@ -10102,8 +10381,9 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "js-yaml": "^4.2.0", - "peggy": "^5.1.0" + "js-yaml": "^4.3.0", + "peggy": "^5.1.0", + "safe-regex2": "^5.1.1" } }, "packages/server-core": { @@ -10119,7 +10399,6 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -10127,7 +10406,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -10136,7 +10414,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^1.17.0", - "next": "^16.2.7", + "next": "^16.2.12", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", diff --git a/package.json b/package.json index f95b720..76a2232 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build": "npm --workspace @subboost/local run build", "test:unit": "vitest run", "test:core": "vitest run --config vitest.core.config.ts", - "lint": "eslint local packages --max-warnings=0", + "lint": "eslint local packages --max-warnings=0 && npm run check:ui-consistency", + "check:ui-consistency": "node scripts/check-ui-consistency.cjs packages local", "check:local-app": "npm --prefix local run check", "local:db:generate": "npm --prefix local run db:generate", "local:lint": "npm --prefix local run lint", @@ -23,9 +24,8 @@ "local:build": "npm --prefix local run build" }, "dependencies": { - "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", + "@prisma/adapter-pg": "^7.9.0", + "@prisma/client": "^7.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -33,7 +33,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -47,9 +46,9 @@ "clsx": "^2.1.0", "dotenv": "^17.4.2", "jose": "^6.2.3", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.0", "lucide-react": "^1.17.0", - "next": "^16.2.7", + "next": "^16.2.12", "peggy": "^5.1.0", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -64,16 +63,17 @@ "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", - "eslint-config-next": "^16.2.7", - "postcss": "8.5.15", - "prisma": "^7.8.0", + "eslint-config-next": "^16.2.12", + "postcss": "8.5.23", + "prisma": "^7.9.0", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "vitest": "^4.1.7" }, "overrides": { - "@hono/node-server": "1.19.13", - "hono": "4.12.25", - "postcss": "8.5.15" + "find-my-way": "9.7.0", + "postcss": "8.5.23", + "sharp": "0.35.3", + "valibot": "1.4.2" } } diff --git a/packages/core/package.json b/packages/core/package.json index 85859fb..1dd887e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,7 +31,8 @@ "./types/*": "./src/types/*.ts" }, "dependencies": { - "js-yaml": "^4.2.0", - "peggy": "^5.1.0" + "js-yaml": "^4.3.0", + "peggy": "^5.1.0", + "safe-regex2": "^5.1.1" } } diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index fb0f8d8..24c870a 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -122,7 +122,7 @@ dns: fake-ip-range: 198.18.0.1/16 default-nameserver: [180.76.76.76, 182.254.118.118, 8.8.8.8, 180.184.2.2] nameserver: [180.76.76.76, 119.29.29.29, 180.184.1.1, 223.5.5.5, 8.8.8.8, "https://223.6.6.6/dns-query#h3=true", "https://dns.alidns.com/dns-query", "https://cloudflare-dns.com/dns-query", "https://doh.pub/dns-query"] - fallback: ["https://000000.dns.nextdns.io/dns-query#h3=true", "https://dns.alidns.com/dns-query", "https://doh.pub/dns-query", "https://public.dns.iij.jp/dns-query", "https://101.101.101.101/dns-query", "https://208.67.220.220/dns-query", "tls://8.8.4.4", "tls://1.0.0.1:853", "https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"] + fallback: ["https://dns.quad9.net/dns-query", "https://dns.alidns.com/dns-query", "https://doh.pub/dns-query", "https://public.dns.iij.jp/dns-query", "https://101.101.101.101/dns-query", "https://208.67.220.220/dns-query", "tls://8.8.4.4", "tls://1.0.0.1:853", "https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"] fallback-filter: {geoip: true, ipcidr: [240.0.0.0/4, 0.0.0.0/32, 127.0.0.1/32], domain: ["+.google.com", "+.facebook.com", "+.twitter.com", "+.youtube.com", "+.xn--ngstr-lra8j.com", "+.google.cn", "+.googleapis.cn", "+.googleapis.com", "+.gvt1.com"]} fake-ip-filter: ["*.lan", "stun.*.*.*", "stun.*.*", time.windows.com, time.nist.gov, time.apple.com, time.asia.apple.com, "*.ntp.org.cn", "*.openwrt.pool.ntp.org", time1.cloud.tencent.com, time.ustc.edu.cn, pool.ntp.org, ntp.ubuntu.com, ntp.aliyun.com, ntp1.aliyun.com, ntp2.aliyun.com, ntp3.aliyun.com, ntp4.aliyun.com, ntp5.aliyun.com, ntp6.aliyun.com, ntp7.aliyun.com, time1.aliyun.com, time2.aliyun.com, time3.aliyun.com, time4.aliyun.com, time5.aliyun.com, time6.aliyun.com, time7.aliyun.com, "*.time.edu.cn", time1.apple.com, time2.apple.com, time3.apple.com, time4.apple.com, time5.apple.com, time6.apple.com, time7.apple.com, time1.google.com, time2.google.com, time3.google.com, time4.google.com, music.163.com, "*.music.163.com", "*.126.net", musicapi.taihe.com, music.taihe.com, songsearch.kugou.com, trackercdn.kugou.com, "*.kuwo.cn", api-jooxtt.sanook.com, api.joox.com, joox.com, y.qq.com, "*.y.qq.com", streamoc.music.tc.qq.com, mobileoc.music.tc.qq.com, isure.stream.qqmusic.qq.com, dl.stream.qqmusic.qq.com, aqqmusic.tc.qq.com, amobile.music.tc.qq.com, "*.xiami.com", "*.music.migu.cn", music.migu.cn, "*.msftconnecttest.com", "*.msftncsi.com", localhost.ptlogin2.qq.com, "*.*.*.srv.nintendo.net", "*.*.stun.playstation.net", "xbox.*.*.microsoft.com", "*.ipv6.microsoft.com", "*.*.xboxlive.com", speedtest.cros.wr.pvp.net] # nameserver-policy 示例(full) diff --git a/packages/core/src/core-contracts.test.ts b/packages/core/src/core-contracts.test.ts index 4279613..18ad9f5 100644 --- a/packages/core/src/core-contracts.test.ts +++ b/packages/core/src/core-contracts.test.ts @@ -173,14 +173,14 @@ describe("custom routing rule set contracts", () => { expect(parseRuleSetTargetValue("module:")).toBeNull(); expect(parseRuleSetTargetValue("bad:value")).toBeNull(); expect(extractRuleSetPathFromUrl("https://example.com/geo/geosite/youtube.mrs?raw=1")).toBe( - "geosite/youtube.mrs" + "https://example.com/geo/geosite/youtube.mrs?raw=1" ); expect(normalizeRuleSetPathInput("/geoip/private.mrs")).toBe("geoip/private.mrs"); expect(buildRuleSetUrlFromPath("geosite/youtube.mrs", "https://rules.example.com/geo/")).toBe( "https://rules.example.com/geo/geosite/youtube.mrs" ); expect(buildRuleSetUrlFromPath("https://cdn.example.com/geosite/youtube.mrs", DEFAULT_RULE_PROVIDER_BASE_URL)).toBe( - `${DEFAULT_RULE_PROVIDER_BASE_URL}/geosite/youtube.mrs` + "https://cdn.example.com/geosite/youtube.mrs" ); }); @@ -231,7 +231,7 @@ describe("custom routing rule set contracts", () => { id: "private", name: "private", behavior: "ipcidr", - path: "geoip/private.mrs", + path: "https://rules.example.com/geo/geoip/private.mrs", target: { kind: "module", id: moduleId, @@ -246,7 +246,7 @@ describe("custom routing rule set contracts", () => { id: "youtube", name: "YouTube", behavior: "domain", - path: "geosite/youtube.mrs", + path: "https://rules.example.com/geo/geosite/youtube.mrs?download=1", target: { kind: "custom", id: "custom", diff --git a/packages/core/src/generator/chain.test.ts b/packages/core/src/generator/chain.test.ts index b0141fa..c0b44a5 100644 --- a/packages/core/src/generator/chain.test.ts +++ b/packages/core/src/generator/chain.test.ts @@ -6,7 +6,6 @@ import { generateDialerProxyGroups, getDialerRelayNodes, getDialerTargetNodes, - suggestDialerGroups, validateDialerConfig, } from "./chain"; @@ -184,25 +183,4 @@ describe("dialer proxy chain helpers", () => { }); }); - it("suggests regional relay groups from node names", () => { - const suggestions = suggestDialerGroups([ - node("US Los Angeles 01"), - node("美国 New York 02"), - node("香港 HK 01"), - node("Unknown 01"), - ]); - - expect(suggestions).toEqual([ - { - name: "🇺🇸 美国中转", - relayNodes: ["US Los Angeles 01", "美国 New York 02"], - description: "使用美国节点作为中转", - }, - { - name: "🇭🇰 香港中转", - relayNodes: ["香港 HK 01"], - description: "使用香港节点作为中转", - }, - ]); - }); }); diff --git a/packages/core/src/generator/chain.ts b/packages/core/src/generator/chain.ts index 6ddd21f..4eb3487 100644 --- a/packages/core/src/generator/chain.ts +++ b/packages/core/src/generator/chain.ts @@ -151,53 +151,3 @@ export function validateDialerConfig( errors, }; } - -/** - * 从节点列表中推荐中转组合 - * 基于节点名称中的地区信息进行智能匹配 - */ -export function suggestDialerGroups( - nodes: ParsedNode[] -): Array<{ name: string; relayNodes: string[]; description: string }> { - const suggestions: Array<{ name: string; relayNodes: string[]; description: string }> = []; - - // 地区关键词 - const regions: Record = { - us: { keywords: ["美国", "US", "USA", "United States", "洛杉矶", "纽约", "西雅图"], emoji: "🇺🇸", name: "美国" }, - hk: { keywords: ["香港", "HK", "Hong Kong", "港"], emoji: "🇭🇰", name: "香港" }, - jp: { keywords: ["日本", "JP", "Japan", "东京", "大阪"], emoji: "🇯🇵", name: "日本" }, - sg: { keywords: ["新加坡", "SG", "Singapore", "狮城"], emoji: "🇸🇬", name: "新加坡" }, - tw: { keywords: ["台湾", "TW", "Taiwan", "台北"], emoji: "🇹🇼", name: "台湾" }, - kr: { keywords: ["韩国", "KR", "Korea", "首尔"], emoji: "🇰🇷", name: "韩国" }, - }; - - // 按地区分类节点 - const nodesByRegion: Record = {}; - - for (const node of nodes) { - const name = node.name.toLowerCase(); - for (const [region, config] of Object.entries(regions)) { - if (config.keywords.some((kw) => name.includes(kw.toLowerCase()))) { - if (!nodesByRegion[region]) { - nodesByRegion[region] = []; - } - nodesByRegion[region].push(node.name); - break; - } - } - } - - // 生成推荐的中转组 - for (const [region, config] of Object.entries(regions)) { - const regionNodes = nodesByRegion[region] || []; - if (regionNodes.length > 0) { - suggestions.push({ - name: `${config.emoji} ${config.name}中转`, - relayNodes: regionNodes.slice(0, 5), // 最多取5个节点 - description: `使用${config.name}节点作为中转`, - }); - } - } - - return suggestions; -} diff --git a/packages/core/src/generator/dns.ts b/packages/core/src/generator/dns.ts index dca1145..7917111 100644 --- a/packages/core/src/generator/dns.ts +++ b/packages/core/src/generator/dns.ts @@ -28,7 +28,7 @@ export const DEFAULT_DNS_CONFIG: DNSConfig = { "https://doh.pub/dns-query", ], fallback: [ - "https://000000.dns.nextdns.io/dns-query#h3=true", + "https://dns.quad9.net/dns-query", "https://dns.alidns.com/dns-query", "https://doh.pub/dns-query", "https://public.dns.iij.jp/dns-query", @@ -128,4 +128,3 @@ export const DEFAULT_DNS_CONFIG: DNSConfig = { "speedtest.cros.wr.pvp.net", ], }; - diff --git a/packages/core/src/generator/proxy-groups.ts b/packages/core/src/generator/proxy-groups.ts index 63d56d5..167549b 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -48,7 +48,7 @@ export const CATEGORY_INFO: Record = { custom: { name: "自定义分组", order: 8 }, }; -export interface GenerateOptions { +export interface ProxyGroupGenerateOptions { nodes: ParsedNode[]; proxyProviderNames?: string[]; enabledModules: string[]; @@ -67,6 +67,9 @@ export interface GenerateOptions { ruleOrder?: string[]; } +/** @deprecated Use `ProxyGroupGenerateOptions`; retained for 2.x source compatibility. */ +export type GenerateOptions = ProxyGroupGenerateOptions; + export { isSubscriptionInfoNodeName }; function getEnabledCustomProxyGroups(customProxyGroups: CustomProxyGroup[]): CustomProxyGroup[] { @@ -130,7 +133,7 @@ const PROXY_GROUP_ORDER: string[] = [ /** * 生成代理组配置 */ -export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { +export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGroup[] { const { nodes, proxyProviderNames = [], @@ -479,7 +482,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { /** * 生成规则提供者配置 */ -export function generateRuleProviders(options: GenerateOptions): Record { +export function generateRuleProviders(options: ProxyGroupGenerateOptions): Record { const { enabledModules, ruleProviderBaseUrl, diff --git a/packages/core/src/generator/yaml.test.ts b/packages/core/src/generator/yaml.test.ts index 6bb6555..6294e21 100644 --- a/packages/core/src/generator/yaml.test.ts +++ b/packages/core/src/generator/yaml.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import yamlParser from "js-yaml"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; import type { ClashConfig } from "@subboost/core/types/config"; @@ -73,6 +74,25 @@ describe("configToYaml", () => { expect(yaml).toBe(["proxies:", "", "proxy-groups:", "", "rule-providers:", "", "rules:"].join("\n")); }); + it("quotes rule scalars that YAML would otherwise truncate or reinterpret", () => { + const rules = [ + "DOMAIN-SUFFIX,example.com,Netflix #1", + "DOMAIN,example.net,Group: Primary", + "MATCH,Select", + ]; + const output = configToYaml({ + proxies: [], + "proxy-groups": [], + "rule-providers": {}, + rules, + } as unknown as ClashConfig); + + expect((yamlParser.load(output) as ClashConfig).rules).toEqual(rules); + expect(output).toContain(' - "DOMAIN-SUFFIX,example.com,Netflix #1"'); + expect(output).toContain(' - "DOMAIN,example.net,Group: Primary"'); + expect(output).toContain(" - MATCH,Select"); + }); + it("collects DNS policy entries from clean string and array values only", () => { expect(collectDnsPolicyEntries(null)).toEqual([]); expect(collectDnsPolicyEntries([])).toEqual([]); diff --git a/packages/core/src/generator/yaml.ts b/packages/core/src/generator/yaml.ts index 7efb8cd..840f589 100644 --- a/packages/core/src/generator/yaml.ts +++ b/packages/core/src/generator/yaml.ts @@ -237,6 +237,19 @@ function toInlineYaml(value: unknown): string { return String(value); } +function toBlockSequenceScalar(value: string): string { + const trimmed = value.trim(); + const ambiguous = + !value || + trimmed !== value || + /[\r\n#]/.test(value) || + /:\s/.test(value) || + /^[\-?:,\[\]{}&*!|>'"%@`]/.test(value) || + /^(?:null|~|true|false|yes|no|on|off)$/i.test(value) || + /^[+-]?(?:\d+|\d*\.\d+|\d+\.\d*)(?:[eE][+-]?\d+)?$/.test(value); + return ambiguous ? `"${escapeYamlDoubleQuotedString(value)}"` : value; +} + function normalizeDnsPolicyValue(value: unknown): DnsPolicyValue | null { if (Array.isArray(value)) { const servers = value @@ -372,7 +385,7 @@ export function configToYaml(config: ClashConfig): string { lines.push("rules:"); if (config.rules) { for (const rule of config.rules) { - lines.push(` - ${rule}`); + lines.push(` - ${toBlockSequenceScalar(rule)}`); } } diff --git a/packages/core/src/mihomo/proxy-sanitizer-boundaries.test.ts b/packages/core/src/mihomo/proxy-sanitizer-boundaries.test.ts new file mode 100644 index 0000000..eb34d5d --- /dev/null +++ b/packages/core/src/mihomo/proxy-sanitizer-boundaries.test.ts @@ -0,0 +1,495 @@ +import { describe, expect, it } from "vitest"; +import { + isMihomoSupportedProxyNode, + isStandardBase64String, + normalizeMihomoVlessForGeneration, + sanitizeMihomoProxyNode, +} from "./proxy-sanitizer"; + +const REALITY_PUBLIC_KEY = "A".repeat(43); +const WIREGUARD_KEY = `${"A".repeat(43)}=`; +const PRIVATE_KEY = ["-----BEGIN OPENSSH ", "PRIVATE ", "KEY-----\nabc\n-----END OPENSSH ", "PRIVATE ", "KEY-----"].join(""); + +describe("Mihomo proxy sanitizer boundaries", () => { + it("covers sanitizer boundary aliases and optional protocol fallbacks", () => { + const ecdsaSsh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": [ + "ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-ecdsa-!bad AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-rsa bad!token", + ], + }); + const certless = sanitizeMihomoProxyNode({ + name: "HTTP", + type: "http", + server: "http.example.com", + port: 80, + fingerprint: 1, + }); + const clientFingerprintAlreadySet = sanitizeMihomoProxyNode({ + name: "Trojan", + type: "trojan", + server: "trojan.example.com", + port: 443, + password: "secret", + fingerprint: "Chrome", + "client-fingerprint": "safari", + }); + const wireguardReservedArray = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: [1, "2", 3], + }); + const xhttpNoReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "ech-opts": { + enable: false, + config: Buffer.from("ech").toString("base64"), + "query-server-name": " ech.example.com ", + }, + }, + }, + }); + + expect(ecdsaSsh).toMatchObject({ + "host-key": ["ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA=="], + }); + expect(certless).not.toHaveProperty("fingerprint"); + expect(clientFingerprintAlreadySet).toMatchObject({ "client-fingerprint": "safari" }); + expect(clientFingerprintAlreadySet).not.toHaveProperty("fingerprint"); + expect(wireguardReservedArray).toHaveProperty("reserved", [1, 2, 3]); + expect(xhttpNoReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "ech-opts": { + enable: false, + config: Buffer.from("ech").toString("base64"), + "query-server-name": "ech.example.com", + }, + }, + }, + }); + expect( + isMihomoSupportedProxyNode({ + type: "trojan", + name: "Trojan", + server: "trojan.example.com", + port: 443, + password: "secret", + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "v2ray-plugin", + }) + ).toBe(true); + }); + + it("rejects explicit malformed optional transport fields", () => { + const ssh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": [ + "ssh-rsa AAAA", + "ssh-dss AAAA comment", + "ssh-ed25519", + "ssh-ecdsa-!bad AAAA", + ], + }); + const invalidReserved = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: "1,2", + }); + const explicitEmptyDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + const invalidDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }); + const invalidMainReality = normalizeMihomoVlessForGeneration({ + name: "Reality", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + "reality-opts": "bad", + }); + const prefixedCertificate = sanitizeMihomoProxyNode({ + name: "HTTPS", + type: "https", + server: "https.example.com", + port: 443, + fingerprint: "SHA256=" + "C".repeat(64).match(/.{1,2}/g)?.join(":"), + }); + + expect(isMihomoSupportedProxyNode({ type: "ss", cipher: "", password: "secret" })).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }) + ).toBe(false); + expect(ssh).toMatchObject({ "host-key": ["ssh-rsa AAAA", "ssh-dss AAAA comment"] }); + expect(invalidReserved).not.toHaveProperty("reserved"); + expect(explicitEmptyDownloadReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(invalidMainReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(prefixedCertificate).toHaveProperty("fingerprint", "c".repeat(64)); + }); + + it("covers conservative sanitizer fallbacks for omitted and malformed optional fields", () => { + const sshWithScalarHostKey = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": "ssh-rsa AAAA", + "private-key": undefined, + "server-fingerprint": 1, + }); + const sshWithEmptyHostKeyMaterial = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": ["ssh-rsa ", "ssh-ecdsa- AAAA", "ssh-ed25519 AAAA"], + }); + const wireguardWithUndefinedOptionalKeys = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": undefined, + "pre-shared-key": undefined, + reserved: "", + }); + const plainHttp = sanitizeMihomoProxyNode({ + name: "HTTP", + type: "http", + server: "http.example.com", + port: 80, + udp: "TRUE", + tls: "FALSE", + alpn: [], + fingerprint: "Not-A-Known-Alias", + "ws-opts": {}, + }); + const vlessWithoutReality = sanitizeMihomoProxyNode({ + name: "VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + "short-id": "not-hex", + }, + "ech-opts": { + enable: "0", + config: "", + "query-server-name": " ", + }, + }, + }, + }); + + expect(sshWithScalarHostKey).not.toHaveProperty("host-key"); + expect(sshWithScalarHostKey).not.toHaveProperty("private-key"); + expect(sshWithScalarHostKey).not.toHaveProperty("server-fingerprint"); + expect(sshWithEmptyHostKeyMaterial).toHaveProperty("host-key", ["ssh-ed25519 AAAA"]); + expect(wireguardWithUndefinedOptionalKeys).toMatchObject({ + "private-key": WIREGUARD_KEY, + }); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("public-key"); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("pre-shared-key"); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("reserved"); + expect(plainHttp).toMatchObject({ udp: true, tls: false }); + expect(plainHttp).not.toHaveProperty("alpn"); + expect(plainHttp).not.toHaveProperty("fingerprint"); + expect(plainHttp).not.toHaveProperty("ws-opts"); + expect(vlessWithoutReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "ech-opts": { + enable: false, + }, + }, + }, + }); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"][ + "reality-opts" + ] + ).not.toHaveProperty("short-id"); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] + ).not.toHaveProperty("config"); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] + ).not.toHaveProperty("query-server-name"); + + expect(isMihomoSupportedProxyNode({ type: "http", name: "HTTP" })).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "wireguard", + name: "WG", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": undefined, + "pre-shared-key": WIREGUARD_KEY, + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + "private-key": "bad", + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + "private-key": PRIVATE_KEY, + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "simple-obfs", + }) + ).toBe(true); + }); + + it("covers remaining protocol support and cleanup fallbacks", () => { + expect(isStandardBase64String("abcd!")).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssr", + name: "SSR", + server: "ssr.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + protocol: "", + obfs: "plain", + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + name: "XHTTP", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, + "xhttp-opts": { + mode: "stream-one", + "download-settings": { + path: "/download", + }, + }, + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + name: "XHTTP", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }) + ).toBe(true); + + const invalidContainers = sanitizeMihomoProxyNode({ + name: "Containers", + type: "vmess", + server: "vmess.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + ech: "bad", + alpn: 443, + "ech-opts": [], + "ws-opts": {}, + fingerprint: "unknown", + }); + const sshWithoutHostKey = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": "ssh-ed25519 AAAA", + "server-fingerprint": `SHA256:${"B".repeat(43)}=`, + }); + const wireguardMissingReserved = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: "1,2", + }); + const noRealityDownloadSettings = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + path: "/download", + }, + }, + }); + const invalidDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }); + const explicitTlsReality = normalizeMihomoVlessForGeneration({ + name: "Reality", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + tls: true, + "client-fingerprint": "edge", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + }); + const noEncryption = sanitizeMihomoProxyNode({ + name: "VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + encryption: " ", + }); + + expect(invalidContainers).not.toHaveProperty("alpn"); + expect(invalidContainers).not.toHaveProperty("ech-opts"); + expect(invalidContainers).not.toHaveProperty("fingerprint"); + expect(invalidContainers).not.toHaveProperty("ws-opts"); + expect(sshWithoutHostKey).not.toHaveProperty("host-key"); + expect(sshWithoutHostKey).toHaveProperty("server-fingerprint", `SHA256:${"B".repeat(43)}=`); + expect(wireguardMissingReserved).not.toHaveProperty("reserved"); + expect(noRealityDownloadSettings).toMatchObject({ + "xhttp-opts": { + "download-settings": { + path: "/download", + }, + }, + }); + expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(explicitTlsReality).toMatchObject({ + tls: true, + "client-fingerprint": "edge", + }); + expect(noEncryption).not.toHaveProperty("encryption"); + }); +}); diff --git a/packages/core/src/mihomo/proxy-sanitizer.test.ts b/packages/core/src/mihomo/proxy-sanitizer.test.ts index 84cb7a9..7397ed2 100644 --- a/packages/core/src/mihomo/proxy-sanitizer.test.ts +++ b/packages/core/src/mihomo/proxy-sanitizer.test.ts @@ -545,485 +545,4 @@ describe("Mihomo proxy sanitizer", () => { }); }); - it("covers sanitizer boundary aliases and optional protocol fallbacks", () => { - const ecdsaSsh = sanitizeMihomoProxyNode({ - name: "SSH", - type: "ssh", - server: "ssh.example.com", - port: 22, - password: "secret", - "host-key": [ - "ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA==", - "ssh-ecdsa-!bad AAAAC3NzaC1lZDI1NTE5AAAAIA==", - "ssh-rsa bad!token", - ], - }); - const certless = sanitizeMihomoProxyNode({ - name: "HTTP", - type: "http", - server: "http.example.com", - port: 80, - fingerprint: 1, - }); - const clientFingerprintAlreadySet = sanitizeMihomoProxyNode({ - name: "Trojan", - type: "trojan", - server: "trojan.example.com", - port: 443, - password: "secret", - fingerprint: "Chrome", - "client-fingerprint": "safari", - }); - const wireguardReservedArray = sanitizeMihomoProxyNode({ - name: "WG", - type: "wireguard", - server: "wg.example.com", - port: 51820, - "private-key": WIREGUARD_KEY, - reserved: [1, "2", 3], - }); - const xhttpNoReality = normalizeMihomoVlessForGeneration({ - name: "XHTTP", - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "xhttp-opts": { - "download-settings": { - "ech-opts": { - enable: false, - config: Buffer.from("ech").toString("base64"), - "query-server-name": " ech.example.com ", - }, - }, - }, - }); - - expect(ecdsaSsh).toMatchObject({ - "host-key": ["ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA=="], - }); - expect(certless).not.toHaveProperty("fingerprint"); - expect(clientFingerprintAlreadySet).toMatchObject({ "client-fingerprint": "safari" }); - expect(clientFingerprintAlreadySet).not.toHaveProperty("fingerprint"); - expect(wireguardReservedArray).toHaveProperty("reserved", [1, 2, 3]); - expect(xhttpNoReality).toMatchObject({ - "xhttp-opts": { - "download-settings": { - "ech-opts": { - enable: false, - config: Buffer.from("ech").toString("base64"), - "query-server-name": "ech.example.com", - }, - }, - }, - }); - expect( - isMihomoSupportedProxyNode({ - type: "trojan", - name: "Trojan", - server: "trojan.example.com", - port: 443, - password: "secret", - }) - ).toBe(true); - expect( - isMihomoSupportedProxyNode({ - type: "ss", - name: "SS", - server: "ss.example.com", - port: 8388, - cipher: "aes-128-gcm", - password: "secret", - plugin: "v2ray-plugin", - }) - ).toBe(true); - }); - - it("rejects explicit malformed optional transport fields", () => { - const ssh = sanitizeMihomoProxyNode({ - name: "SSH", - type: "ssh", - server: "ssh.example.com", - port: 22, - password: "secret", - "host-key": [ - "ssh-rsa AAAA", - "ssh-dss AAAA comment", - "ssh-ed25519", - "ssh-ecdsa-!bad AAAA", - ], - }); - const invalidReserved = sanitizeMihomoProxyNode({ - name: "WG", - type: "wireguard", - server: "wg.example.com", - port: 51820, - "private-key": WIREGUARD_KEY, - reserved: "1,2", - }); - const explicitEmptyDownloadReality = normalizeMihomoVlessForGeneration({ - name: "XHTTP", - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "reality-opts": { - "public-key": REALITY_PUBLIC_KEY, - }, - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": "", - }, - }, - }, - }); - const invalidDownloadReality = normalizeMihomoVlessForGeneration({ - name: "XHTTP", - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "reality-opts": { - "public-key": REALITY_PUBLIC_KEY, - }, - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": "bad", - }, - }, - }, - }); - const invalidMainReality = normalizeMihomoVlessForGeneration({ - name: "Reality", - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - "reality-opts": "bad", - }); - const prefixedCertificate = sanitizeMihomoProxyNode({ - name: "HTTPS", - type: "https", - server: "https.example.com", - port: 443, - fingerprint: "SHA256=" + "C".repeat(64).match(/.{1,2}/g)?.join(":"), - }); - - expect(isMihomoSupportedProxyNode({ type: "ss", cipher: "", password: "secret" })).toBe(false); - expect( - isMihomoSupportedProxyNode({ - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "reality-opts": { - "public-key": REALITY_PUBLIC_KEY, - }, - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": "bad", - }, - }, - }, - }) - ).toBe(false); - expect(ssh).toMatchObject({ "host-key": ["ssh-rsa AAAA", "ssh-dss AAAA comment"] }); - expect(invalidReserved).not.toHaveProperty("reserved"); - expect(explicitEmptyDownloadReality).toMatchObject({ - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": "", - }, - }, - }, - }); - expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); - expect(invalidMainReality).toHaveProperty("_subboost-invalid-mihomo-node", true); - expect(prefixedCertificate).toHaveProperty("fingerprint", "c".repeat(64)); - }); - - it("covers conservative sanitizer fallbacks for omitted and malformed optional fields", () => { - const sshWithScalarHostKey = sanitizeMihomoProxyNode({ - name: "SSH", - type: "ssh", - server: "ssh.example.com", - port: 22, - password: "secret", - "host-key": "ssh-rsa AAAA", - "private-key": undefined, - "server-fingerprint": 1, - }); - const sshWithEmptyHostKeyMaterial = sanitizeMihomoProxyNode({ - name: "SSH", - type: "ssh", - server: "ssh.example.com", - port: 22, - password: "secret", - "host-key": ["ssh-rsa ", "ssh-ecdsa- AAAA", "ssh-ed25519 AAAA"], - }); - const wireguardWithUndefinedOptionalKeys = sanitizeMihomoProxyNode({ - name: "WG", - type: "wireguard", - server: "wg.example.com", - port: 51820, - "private-key": WIREGUARD_KEY, - "public-key": undefined, - "pre-shared-key": undefined, - reserved: "", - }); - const plainHttp = sanitizeMihomoProxyNode({ - name: "HTTP", - type: "http", - server: "http.example.com", - port: 80, - udp: "TRUE", - tls: "FALSE", - alpn: [], - fingerprint: "Not-A-Known-Alias", - "ws-opts": {}, - }); - const vlessWithoutReality = sanitizeMihomoProxyNode({ - name: "VLESS", - type: "vless", - server: "vless.example.com", - port: 443, - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": REALITY_PUBLIC_KEY, - "short-id": "not-hex", - }, - "ech-opts": { - enable: "0", - config: "", - "query-server-name": " ", - }, - }, - }, - }); - - expect(sshWithScalarHostKey).not.toHaveProperty("host-key"); - expect(sshWithScalarHostKey).not.toHaveProperty("private-key"); - expect(sshWithScalarHostKey).not.toHaveProperty("server-fingerprint"); - expect(sshWithEmptyHostKeyMaterial).toHaveProperty("host-key", ["ssh-ed25519 AAAA"]); - expect(wireguardWithUndefinedOptionalKeys).toMatchObject({ - "private-key": WIREGUARD_KEY, - }); - expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("public-key"); - expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("pre-shared-key"); - expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("reserved"); - expect(plainHttp).toMatchObject({ udp: true, tls: false }); - expect(plainHttp).not.toHaveProperty("alpn"); - expect(plainHttp).not.toHaveProperty("fingerprint"); - expect(plainHttp).not.toHaveProperty("ws-opts"); - expect(vlessWithoutReality).toMatchObject({ - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": REALITY_PUBLIC_KEY, - }, - "ech-opts": { - enable: false, - }, - }, - }, - }); - expect( - (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"][ - "reality-opts" - ] - ).not.toHaveProperty("short-id"); - expect( - (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] - ).not.toHaveProperty("config"); - expect( - (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] - ).not.toHaveProperty("query-server-name"); - - expect(isMihomoSupportedProxyNode({ type: "http", name: "HTTP" })).toBe(true); - expect( - isMihomoSupportedProxyNode({ - type: "wireguard", - name: "WG", - server: "wg.example.com", - port: 51820, - "private-key": WIREGUARD_KEY, - "public-key": undefined, - "pre-shared-key": WIREGUARD_KEY, - }) - ).toBe(true); - expect( - isMihomoSupportedProxyNode({ - type: "ssh", - name: "SSH", - server: "ssh.example.com", - port: 22, - "private-key": "bad", - }) - ).toBe(false); - expect( - isMihomoSupportedProxyNode({ - type: "ssh", - name: "SSH", - server: "ssh.example.com", - port: 22, - "private-key": PRIVATE_KEY, - }) - ).toBe(true); - expect( - isMihomoSupportedProxyNode({ - type: "ss", - name: "SS", - server: "ss.example.com", - port: 8388, - cipher: "aes-128-gcm", - password: "secret", - plugin: "simple-obfs", - }) - ).toBe(true); - }); - - it("covers remaining protocol support and cleanup fallbacks", () => { - expect(isStandardBase64String("abcd!")).toBe(false); - expect( - isMihomoSupportedProxyNode({ - type: "ssr", - name: "SSR", - server: "ssr.example.com", - port: 8388, - cipher: "aes-128-gcm", - password: "secret", - protocol: "", - obfs: "plain", - }) - ).toBe(false); - expect( - isMihomoSupportedProxyNode({ - type: "vless", - name: "XHTTP", - server: "vless.example.com", - port: 443, - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, - "xhttp-opts": { - mode: "stream-one", - "download-settings": { - path: "/download", - }, - }, - }) - ).toBe(false); - expect( - isMihomoSupportedProxyNode({ - type: "vless", - name: "XHTTP", - server: "vless.example.com", - port: 443, - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": "", - }, - }, - }, - }) - ).toBe(true); - - const invalidContainers = sanitizeMihomoProxyNode({ - name: "Containers", - type: "vmess", - server: "vmess.example.com", - port: 443, - uuid: "11111111-1111-4111-8111-111111111111", - ech: "bad", - alpn: 443, - "ech-opts": [], - "ws-opts": {}, - fingerprint: "unknown", - }); - const sshWithoutHostKey = sanitizeMihomoProxyNode({ - name: "SSH", - type: "ssh", - server: "ssh.example.com", - port: 22, - password: "secret", - "host-key": "ssh-ed25519 AAAA", - "server-fingerprint": `SHA256:${"B".repeat(43)}=`, - }); - const wireguardMissingReserved = sanitizeMihomoProxyNode({ - name: "WG", - type: "wireguard", - server: "wg.example.com", - port: 51820, - "private-key": WIREGUARD_KEY, - reserved: "1,2", - }); - const noRealityDownloadSettings = normalizeMihomoVlessForGeneration({ - name: "XHTTP", - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "xhttp-opts": { - "download-settings": { - path: "/download", - }, - }, - }); - const invalidDownloadReality = normalizeMihomoVlessForGeneration({ - name: "XHTTP", - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - network: "xhttp", - "xhttp-opts": { - "download-settings": { - "reality-opts": { - "public-key": "bad", - }, - }, - }, - }); - const explicitTlsReality = normalizeMihomoVlessForGeneration({ - name: "Reality", - type: "vless", - uuid: "11111111-1111-4111-8111-111111111111", - tls: true, - "client-fingerprint": "edge", - "reality-opts": { - "public-key": REALITY_PUBLIC_KEY, - }, - }); - const noEncryption = sanitizeMihomoProxyNode({ - name: "VLESS", - type: "vless", - server: "vless.example.com", - port: 443, - uuid: "11111111-1111-4111-8111-111111111111", - encryption: " ", - }); - - expect(invalidContainers).not.toHaveProperty("alpn"); - expect(invalidContainers).not.toHaveProperty("ech-opts"); - expect(invalidContainers).not.toHaveProperty("fingerprint"); - expect(invalidContainers).not.toHaveProperty("ws-opts"); - expect(sshWithoutHostKey).not.toHaveProperty("host-key"); - expect(sshWithoutHostKey).toHaveProperty("server-fingerprint", `SHA256:${"B".repeat(43)}=`); - expect(wireguardMissingReserved).not.toHaveProperty("reserved"); - expect(noRealityDownloadSettings).toMatchObject({ - "xhttp-opts": { - "download-settings": { - path: "/download", - }, - }, - }); - expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); - expect(explicitTlsReality).toMatchObject({ - tls: true, - "client-fingerprint": "edge", - }); - expect(noEncryption).not.toHaveProperty("encryption"); - }); }); diff --git a/packages/core/src/parser/clash-yaml.test.ts b/packages/core/src/parser/clash-yaml.test.ts index 102aef5..d36ad65 100644 --- a/packages/core/src/parser/clash-yaml.test.ts +++ b/packages/core/src/parser/clash-yaml.test.ts @@ -208,6 +208,25 @@ proxies: expect(mixed.errors[0]).toContain('节点 "Bad" 解析失败'); }); + it("repairs large malformed proxy lists without quadratic scanning", () => { + const count = 2_000; + const rows = ["proxies:"]; + for (let index = 0; index < count; index += 1) { + rows.push(` - name: Node ${index}`); + rows.push(" type: ss"); + rows.push(` server: node-${index}.example.com`); + rows.push(" port: 8388"); + } + + const startedAt = performance.now(); + const result = parseClashYaml(rows.join("\n")); + const elapsedMs = performance.now() - startedAt; + + expect(result.errors).toEqual([]); + expect(result.nodes).toHaveLength(count); + expect(elapsedMs).toBeLessThan(1_500); + }, 10_000); + it("parses consistently indented root flow proxy lists", () => { for (const spaces of [0, 1, 2, 4]) { const indent = " ".repeat(spaces); diff --git a/packages/core/src/parser/clash-yaml.ts b/packages/core/src/parser/clash-yaml.ts index 26381e6..3d5245b 100644 --- a/packages/core/src/parser/clash-yaml.ts +++ b/packages/core/src/parser/clash-yaml.ts @@ -106,6 +106,53 @@ function repairRootFlowProxyListIndent(input: string): string { return lines.join("\n"); } +function repairInlineListIndent(input: string): string { + // 修复常见的“列表项首行内联 key(- name: xxx)但后续 key 又多缩进”的不合法 YAML。 + // 逐个列表项消费其后续块,确保每一行最多检查一次,避免大型订阅退化为 O(n²)。 + const lines = input.split(/\r?\n/); + const out: string[] = []; + + for (let index = 0; index < lines.length; ) { + const line = lines[index]; + const match = line.match(/^(\s*)-\s*(name\s*:\s*.+)$/); + if (!match) { + out.push(line); + index += 1; + continue; + } + + const dashIndent = match[1].length; + const keyIndent = dashIndent + 2; + let minIndent = Number.POSITIVE_INFINITY; + let blockEnd = index + 1; + + while (blockEnd < lines.length) { + const nextLine = lines[blockEnd]; + const trimmed = nextLine.trim(); + if (!trimmed || trimmed.startsWith("#")) { + blockEnd += 1; + continue; + } + + const indent = countLeadingSpaces(nextLine); + if (indent <= dashIndent) break; + minIndent = Math.min(minIndent, indent); + blockEnd += 1; + } + + if (minIndent !== Number.POSITIVE_INFINITY && minIndent > keyIndent) { + out.push(`${" ".repeat(dashIndent)}-`); + out.push(`${" ".repeat(minIndent)}${match[2]}`); + } else { + out.push(line); + } + out.push(...lines.slice(index + 1, blockEnd)); + index = blockEnd; + } + + return out.join("\n"); +} + /** * 解析 Clash YAML 配置 */ @@ -116,55 +163,6 @@ export function parseClashYaml(content: string): ParseResult { try { const normalizeTabs = (s: string) => s.replace(/\t/g, " "); - const repairInlineListIndent = (input: string): string => { - // 修复常见的“列表项首行内联 key(- name: xxx)但后续 key 又多缩进”的不合法 YAML - // 例如(不合法): - // - name: ss-A - // type: ss - // 修复为(合法): - // - - // name: ss-A - // type: ss - const lines = input.split(/\r?\n/); - const out: string[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const m = line.match(/^(\s*)-\s*(name\s*:\s*.+)$/); - if (!m) { - out.push(line); - continue; - } - - const dashIndent = m[1].length; - const namePair = m[2]; - const keyIndent = dashIndent + 2; - - // 计算该列表项后续块内最小缩进(用于对齐 name/type/server 等) - let minIndent = Number.POSITIVE_INFINITY; - for (let j = i + 1; j < lines.length; j++) { - const l = lines[j]; - if (!l.trim() || l.trim().startsWith("#")) continue; - - const indent = (l.match(/^(\s*)/)?.[1].length ?? 0); - // 缩进回退到当前列表项的 '-' 缩进(或更浅)时,说明已经离开该列表项 - if (indent <= dashIndent) break; - minIndent = Math.min(minIndent, indent); - } - - // 仅当后续 key 明显比内联 key 更深时才修复,避免误改合法 YAML - if (minIndent !== Number.POSITIVE_INFINITY && minIndent > keyIndent) { - out.push(`${" ".repeat(dashIndent)}-`); - out.push(`${" ".repeat(minIndent)}${namePair}`); - continue; - } - - out.push(line); - } - - return out.join("\n"); - }; - const tryLoad = (raw: string): unknown => yaml.load(raw) as unknown; const normalizedContent = normalizeClashYamlScalarText(normalizeTabs(content)); diff --git a/packages/core/src/parser/config-line-parser.test.ts b/packages/core/src/parser/config-line-parser.test.ts index c21ae05..bf90f70 100644 --- a/packages/core/src/parser/config-line-parser.test.ts +++ b/packages/core/src/parser/config-line-parser.test.ts @@ -1,216 +1,5 @@ import { describe, expect, it } from "vitest"; -import { looksLikeConfigLine } from "./config-line-parser"; import { mustParseConfigLine } from "./config-line-parser.test-helpers"; -import { - applyCommonNodeParams, - applyTransport, - inferSkipCertVerify, - isUuidLike, - parseBooleanish, - parseIntParam, - parseStringList, - parseWsHeaders, - tokenizeConfigLine, -} from "./config-line-tokenizer"; - -describe("config line tokenizer helpers", () => { - it("tokenizes quoted config lines and mirrors dashed/underscored params", () => { - expect(looksLikeConfigLine("Node = ss, example.com, 8388")).toBe(true); - expect(looksLikeConfigLine("# Node = ss, example.com, 8388")).toBe(false); - - const tokenized = tokenizeConfigLine( - '"My Node" = ss, "ss.example.com", 8388, encrypt_method=aes-128-gcm, password=secret, extra' - ); - - expect(tokenized).toMatchObject({ - name: "My Node", - type: "ss", - host: "ss.example.com", - port: 8388, - params: { - encrypt_method: "aes-128-gcm", - "encrypt-method": "aes-128-gcm", - password: "secret", - }, - extras: ["extra"], - }); - expect(() => tokenizeConfigLine("broken")).toThrow("无效的配置行格式"); - expect(() => tokenizeConfigLine("Bad = ss, example.com, 70000")).toThrow("配置行中的地址或端口无效"); - expect(tokenizeConfigLine("Ignored = ss, ignored.example.com, 8388, =empty, flag")).toMatchObject({ - params: {}, - extras: ["flag"], - }); - }); - - it("normalizes common primitive params", () => { - expect(parseBooleanish("yes")).toBe(true); - expect(parseBooleanish("off")).toBe(false); - expect(parseBooleanish("maybe")).toBeUndefined(); - expect(parseStringList(undefined)).toBeUndefined(); - expect(parseStringList(" , ")).toBeUndefined(); - expect(parseStringList("a, b,,c")).toEqual(["a", "b", "c"]); - expect(parseWsHeaders(undefined)).toBeUndefined(); - expect(parseWsHeaders("bad|also-bad")).toBeUndefined(); - expect(parseWsHeaders("Host:|:missing|Good:yes")).toEqual({ Good: "yes" }); - expect(parseWsHeaders('Host:cdn.example.com|X-Test:"yes"')).toEqual({ - Host: "cdn.example.com", - "X-Test": "yes", - }); - expect(parseIntParam(undefined)).toBeUndefined(); - expect(parseIntParam("42ms")).toBe(42); - expect(parseIntParam("x")).toBeUndefined(); - expect(isUuidLike("11111111-1111-4111-8111-111111111111")).toBe(true); - expect(isUuidLike("not-a-uuid")).toBe(false); - expect(inferSkipCertVerify({ "skip-cert-verify": "false" })).toBe(false); - expect(inferSkipCertVerify({ "tls-verification": "false" })).toBe(true); - expect(inferSkipCertVerify({ "tls-verification": "true" })).toBeUndefined(); - expect(inferSkipCertVerify({ "allow-insecure": "0" })).toBe(false); - }); - - it("applies shared node params to non-VMess protocols and rare TLS aliases", () => { - const trojan: Record = { type: "trojan" }; - applyCommonNodeParams(trojan, { - peer: "trojan-sni.example.com", - "tls-cert-sha256": "cert", - "tls_pubkey_sha256": "pub", - "disable-sni": "true", - "block-quic": "true", - "udp-port": "53", - "fast-open": "false", - "shadow-tls-version": "3", - "shadow-tls-sni": "shadow.example.com", - "shadow-tls-password": "shadow-secret", - }); - - expect(trojan).toMatchObject({ - sni: "trojan-sni.example.com", - "tls-cert-sha256": "cert", - "tls-pubkey-sha256": "pub", - "disable-sni": true, - "block-quic": true, - "udp-port": 53, - tfo: false, - "shadow-tls-version": 3, - "shadow-tls-sni": "shadow.example.com", - "shadow-tls-password": "shadow-secret", - }); - - const hysteria2: Record = { type: "hysteria2" }; - applyCommonNodeParams(hysteria2, { fingerprint: "chrome" }); - expect(hysteria2).toMatchObject({ fingerprint: "chrome" }); - }); - - it("applies transport helpers across default, header, and xHTTP edge branches", () => { - const defaultWs: Record = {}; - applyTransport(defaultWs, { "ws-path": "/ws?ed=128", "ws-headers": "Host:from-header.example.com|X-Test:yes" }, { - defaultTransport: "ws", - }); - expect(defaultWs).toMatchObject({ - network: "ws", - "ws-opts": { - path: "/ws", - headers: { - Host: "from-header.example.com", - "X-Test": "yes", - }, - "early-data-header-name": "Sec-WebSocket-Protocol", - "max-early-data": 128, - }, - }); - - const plainGrpc: Record = {}; - applyTransport(plainGrpc, { transport: "grpc", path: "/svc" }); - expect(plainGrpc).toMatchObject({ - network: "grpc", - "grpc-opts": { - "grpc-service-name": "svc", - }, - }); - - const blankHttp: Record = {}; - applyTransport(blankHttp, { transport: "http", method: " ", path: " , " }); - expect(blankHttp).toMatchObject({ - network: "http", - "http-opts": { - method: "GET", - path: ["/"], - headers: undefined, - }, - }); - - const xhttp: Record = {}; - applyTransport(xhttp, { - transport: "xhttp", - path: "/x", - host: "cdn.example.com", - mode: "packet-up", - "xhttp-headers": "User-Agent:SubBoost", - "no-grpc-header": "off", - "sc-max-each-post-bytes": "bad", - "download-headers": "Accept:yaml", - }, { - allowedTransports: ["tcp", "xhttp"], - }); - expect(xhttp).toMatchObject({ - network: "xhttp", - "xhttp-opts": { - path: "/x", - host: "cdn.example.com", - mode: "packet-up", - headers: { "User-Agent": "SubBoost" }, - "no-grpc-header": false, - "download-settings": { - headers: { Accept: "yaml" }, - }, - }, - }); - - expect(() => applyTransport({}, { transport: "udp" }, { allowedTransports: ["tcp"], protocolName: "测试" })).toThrow( - "不支持的 测试 传输层" - ); - expect(() => applyTransport({}, { transport: " " }, { allowedTransports: ["tcp"] })).toThrow( - "transport=(empty)" - ); - - const tcp: Record = {}; - applyTransport(tcp, { transport: "tcp" }); - expect(tcp).toMatchObject({ network: "tcp" }); - - const xhttpAliases: Record = {}; - applyTransport(xhttpAliases, { - network: "xhttp", - path: "/alias", - headers: "Host:edge.example.com", - "max-connections": "2", - "c-max-reuse-times": "3", - "h-max-request-times": "4", - "h-max-reusable-secs": "5", - no_grpc_header: "yes", - sc_max_each_post_bytes: "4096", - downloadheaders: "Accept:yaml", - }, { - allowedTransports: ["tcp", "xhttp"], - }); - expect(xhttpAliases).toMatchObject({ - network: "xhttp", - "xhttp-opts": { - path: "/alias", - headers: { Host: "edge.example.com" }, - "no-grpc-header": true, - "sc-max-each-post-bytes": 4096, - "reuse-settings": { - "max-connections": "2", - "c-max-reuse-times": "3", - "h-max-request-times": "4", - "h-max-reusable-secs": "5", - }, - "download-settings": { - headers: { Accept: "yaml" }, - }, - }, - }); - }); -}); describe("config line parser", () => { it("builds VMess and VLESS transport options", () => { diff --git a/packages/core/src/parser/config-line-tokenizer.test.ts b/packages/core/src/parser/config-line-tokenizer.test.ts new file mode 100644 index 0000000..7894147 --- /dev/null +++ b/packages/core/src/parser/config-line-tokenizer.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeConfigLine } from "./config-line-parser"; +import { + applyCommonNodeParams, + applyTransport, + inferSkipCertVerify, + isUuidLike, + parseBooleanish, + parseIntParam, + parseStringList, + parseWsHeaders, + tokenizeConfigLine, +} from "./config-line-tokenizer"; + +describe("config line tokenizer helpers", () => { + it("tokenizes quoted config lines and mirrors dashed/underscored params", () => { + expect(looksLikeConfigLine("Node = ss, example.com, 8388")).toBe(true); + expect(looksLikeConfigLine("# Node = ss, example.com, 8388")).toBe(false); + + const tokenized = tokenizeConfigLine( + '"My Node" = ss, "ss.example.com", 8388, encrypt_method=aes-128-gcm, password=secret, extra' + ); + + expect(tokenized).toMatchObject({ + name: "My Node", + type: "ss", + host: "ss.example.com", + port: 8388, + params: { + encrypt_method: "aes-128-gcm", + "encrypt-method": "aes-128-gcm", + password: "secret", + }, + extras: ["extra"], + }); + expect(() => tokenizeConfigLine("broken")).toThrow("无效的配置行格式"); + expect(() => tokenizeConfigLine("Bad = ss, example.com, 70000")).toThrow("配置行中的地址或端口无效"); + for (const invalidPort of ["1.5", "0x50", "8e3", "+80", "-1"]) { + expect(() => tokenizeConfigLine(`Bad = ss, example.com, ${invalidPort}`), invalidPort).toThrow( + "配置行中的地址或端口无效" + ); + } + expect(tokenizeConfigLine("Ignored = ss, ignored.example.com, 8388, =empty, flag")).toMatchObject({ + params: {}, + extras: ["flag"], + }); + }); + + it("normalizes common primitive params", () => { + expect(parseBooleanish("yes")).toBe(true); + expect(parseBooleanish("off")).toBe(false); + expect(parseBooleanish("maybe")).toBeUndefined(); + expect(parseStringList(undefined)).toBeUndefined(); + expect(parseStringList(" , ")).toBeUndefined(); + expect(parseStringList("a, b,,c")).toEqual(["a", "b", "c"]); + expect(parseWsHeaders(undefined)).toBeUndefined(); + expect(parseWsHeaders("bad|also-bad")).toBeUndefined(); + expect(parseWsHeaders("Host:|:missing|Good:yes")).toEqual({ Good: "yes" }); + expect(parseWsHeaders('Host:cdn.example.com|X-Test:"yes"')).toEqual({ + Host: "cdn.example.com", + "X-Test": "yes", + }); + expect(parseIntParam(undefined)).toBeUndefined(); + expect(parseIntParam("42ms")).toBe(42); + expect(parseIntParam("x")).toBeUndefined(); + expect(isUuidLike("11111111-1111-4111-8111-111111111111")).toBe(true); + expect(isUuidLike("not-a-uuid")).toBe(false); + expect(inferSkipCertVerify({ "skip-cert-verify": "false" })).toBe(false); + expect(inferSkipCertVerify({ "tls-verification": "false" })).toBe(true); + expect(inferSkipCertVerify({ "tls-verification": "true" })).toBeUndefined(); + expect(inferSkipCertVerify({ "allow-insecure": "0" })).toBe(false); + }); + + it("applies shared node params to non-VMess protocols and rare TLS aliases", () => { + const trojan: Record = { type: "trojan" }; + applyCommonNodeParams(trojan, { + peer: "trojan-sni.example.com", + "tls-cert-sha256": "cert", + "tls_pubkey_sha256": "pub", + "disable-sni": "true", + "block-quic": "true", + "udp-port": "53", + "fast-open": "false", + "shadow-tls-version": "3", + "shadow-tls-sni": "shadow.example.com", + "shadow-tls-password": "shadow-secret", + }); + + expect(trojan).toMatchObject({ + sni: "trojan-sni.example.com", + "tls-cert-sha256": "cert", + "tls-pubkey-sha256": "pub", + "disable-sni": true, + "block-quic": true, + "udp-port": 53, + tfo: false, + "shadow-tls-version": 3, + "shadow-tls-sni": "shadow.example.com", + "shadow-tls-password": "shadow-secret", + }); + + const hysteria2: Record = { type: "hysteria2" }; + applyCommonNodeParams(hysteria2, { fingerprint: "chrome" }); + expect(hysteria2).toMatchObject({ fingerprint: "chrome" }); + }); + + it("applies transport helpers across default, header, and xHTTP edge branches", () => { + const defaultWs: Record = {}; + applyTransport(defaultWs, { "ws-path": "/ws?ed=128", "ws-headers": "Host:from-header.example.com|X-Test:yes" }, { + defaultTransport: "ws", + }); + expect(defaultWs).toMatchObject({ + network: "ws", + "ws-opts": { + path: "/ws", + headers: { + Host: "from-header.example.com", + "X-Test": "yes", + }, + "early-data-header-name": "Sec-WebSocket-Protocol", + "max-early-data": 128, + }, + }); + + const plainGrpc: Record = {}; + applyTransport(plainGrpc, { transport: "grpc", path: "/svc" }); + expect(plainGrpc).toMatchObject({ + network: "grpc", + "grpc-opts": { + "grpc-service-name": "svc", + }, + }); + + const blankHttp: Record = {}; + applyTransport(blankHttp, { transport: "http", method: " ", path: " , " }); + expect(blankHttp).toMatchObject({ + network: "http", + "http-opts": { + method: "GET", + path: ["/"], + headers: undefined, + }, + }); + + const xhttp: Record = {}; + applyTransport(xhttp, { + transport: "xhttp", + path: "/x", + host: "cdn.example.com", + mode: "packet-up", + "xhttp-headers": "User-Agent:SubBoost", + "no-grpc-header": "off", + "sc-max-each-post-bytes": "bad", + "download-headers": "Accept:yaml", + }, { + allowedTransports: ["tcp", "xhttp"], + }); + expect(xhttp).toMatchObject({ + network: "xhttp", + "xhttp-opts": { + path: "/x", + host: "cdn.example.com", + mode: "packet-up", + headers: { "User-Agent": "SubBoost" }, + "no-grpc-header": false, + "download-settings": { + headers: { Accept: "yaml" }, + }, + }, + }); + + expect(() => applyTransport({}, { transport: "udp" }, { allowedTransports: ["tcp"], protocolName: "测试" })).toThrow( + "不支持的 测试 传输层" + ); + expect(() => applyTransport({}, { transport: " " }, { allowedTransports: ["tcp"] })).toThrow( + "transport=(empty)" + ); + + const tcp: Record = {}; + applyTransport(tcp, { transport: "tcp" }); + expect(tcp).toMatchObject({ network: "tcp" }); + + const xhttpAliases: Record = {}; + applyTransport(xhttpAliases, { + network: "xhttp", + path: "/alias", + headers: "Host:edge.example.com", + "max-connections": "2", + "c-max-reuse-times": "3", + "h-max-request-times": "4", + "h-max-reusable-secs": "5", + no_grpc_header: "yes", + sc_max_each_post_bytes: "4096", + downloadheaders: "Accept:yaml", + }, { + allowedTransports: ["tcp", "xhttp"], + }); + expect(xhttpAliases).toMatchObject({ + network: "xhttp", + "xhttp-opts": { + path: "/alias", + headers: { Host: "edge.example.com" }, + "no-grpc-header": true, + "sc-max-each-post-bytes": 4096, + "reuse-settings": { + "max-connections": "2", + "c-max-reuse-times": "3", + "h-max-request-times": "4", + "h-max-reusable-secs": "5", + }, + "download-settings": { + headers: { Accept: "yaml" }, + }, + }, + }); + }); +}); diff --git a/packages/core/src/parser/config-line-tokenizer.ts b/packages/core/src/parser/config-line-tokenizer.ts index 4c95cc1..f177746 100644 --- a/packages/core/src/parser/config-line-tokenizer.ts +++ b/packages/core/src/parser/config-line-tokenizer.ts @@ -39,8 +39,9 @@ export function tokenizeConfigLine(line: string): { const type = tokens[0].toLowerCase(); const host = stripQuotes(tokens[1]); - const port = Number(tokens[2]); - if (!host || !Number.isFinite(port) || port < 1 || port > 65535) { + const portText = stripQuotes(tokens[2]); + const port = Number(portText); + if (!host || !/^\d+$/.test(portText) || !Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("配置行中的地址或端口无效"); } diff --git a/packages/core/src/parser/content-parsers.test.ts b/packages/core/src/parser/content-parsers.test.ts index 81749a6..1f461e0 100644 --- a/packages/core/src/parser/content-parsers.test.ts +++ b/packages/core/src/parser/content-parsers.test.ts @@ -30,6 +30,15 @@ describe("content parser registry helpers", () => { expect(isClashYamlContent("proxy-providers: {}")).toBe(true); expect(isClashYamlContent("- type: hysteria2\n server: hy2.example.com\n ports: 10000-10100")).toBe(true); expect(isClashYamlContent("ss://not-yaml")).toBe(false); + expect(isClashYamlContent(ssLink("proxies:"))).toBe(false); + }); + + it("does not misclassify a link fragment as YAML and falls through zero-node YAML parsers", () => { + expect(parseSubscription(ssLink("proxies:")).nodes[0]).toMatchObject({ name: "proxies:", type: "ss" }); + + const mixed = parseSubscription(`proxy-providers: {}\n${ssLink("Link after YAML")}`); + expect(mixed.nodes[0]).toMatchObject({ name: "Link after YAML", type: "ss" }); + expect(mixed.errors.length).toBeGreaterThan(0); }); it("detects and parses top-level inline flow proxy arrays", () => { diff --git a/packages/core/src/parser/content-parsers.ts b/packages/core/src/parser/content-parsers.ts index c1a0432..578b493 100644 --- a/packages/core/src/parser/content-parsers.ts +++ b/packages/core/src/parser/content-parsers.ts @@ -80,11 +80,7 @@ function looksLikeInlineFlowProxyList(content: string): boolean { } export function isClashYamlContent(content: string): boolean { - if ( - content.includes("proxies:") || - content.includes("proxy-groups:") || - content.includes("proxy-providers:") - ) { + if (/^(?:[ \t]*)(?:proxies|proxy-groups|proxy-providers)[ \t]*:/m.test(content)) { return true; } @@ -188,7 +184,8 @@ export function parseSubscriptionContentByRegistry(content: string): ParseResult if (parser.name === "link-lines") { return buildParseResult(result.nodes, [...accumulatedErrors, ...result.errors]); } - return result; + if (result.nodes.length > 0) return result; + accumulatedErrors.push(...result.errors); } catch (error) { if (parser.name === "clash-yaml") { accumulatedErrors.push(`Clash YAML 解析失败: ${error instanceof Error ? error.message : "未知错误"}`); @@ -201,4 +198,3 @@ export function parseSubscriptionContentByRegistry(content: string): ParseResult return buildParseResult([], accumulatedErrors); } - diff --git a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts index 59f1228..e6708a4 100644 --- a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts +++ b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts @@ -34,6 +34,20 @@ describe("parsePlatformProxyLine", () => { }); }); + it("keeps ws=false disabled and accepts IP or single-label SNI values", () => { + const disabledWs = parsePlatformProxyLine( + "No WS = vmess, vmess.example.com, 443, username=11111111-1111-4111-8111-111111111111, ws=false, ws-path=/ignored, tls=true, sni=127.0.0.1" + ); + const localSni = parsePlatformProxyLine( + "Local SNI = anytls, anytls.example.com, 443, password=secret, sni=localhost" + ); + + expect(disabledWs).toMatchObject({ servername: "127.0.0.1" }); + expect(disabledWs).not.toHaveProperty("network"); + expect(disabledWs).not.toHaveProperty("ws-opts"); + expect(localSni).toMatchObject({ sni: "localhost" }); + }); + it("parses Surge WireGuard through its referenced section", () => { const sections = new Map([ [ diff --git a/packages/core/src/parser/platform/peggy/surge.js b/packages/core/src/parser/platform/peggy/surge.js index 2998758..5d426e8 100644 --- a/packages/core/src/parser/platform/peggy/surge.js +++ b/packages/core/src/parser/platform/peggy/surge.js @@ -191,7 +191,7 @@ username = & { password = comma match:[^,]+ { proxy.password = match.join("").replace(/^"(.*)"$/, '$1').replace(/^'(.*?)'$/, '$1'); } tls = comma "tls" equals flag:bool { proxy.tls = flag; } -sni = comma "sni" equals sni:("off"/domain) { +sni = comma "sni" equals sni:("off"/server) { if (sni === "off") { proxy["disable-sni"] = true; } else { @@ -214,7 +214,7 @@ method = comma "encrypt-method" equals cipher:cipher { } cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"camellia-128-cfb"/"camellia-192-cfb"/"camellia-256-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"idea-cfb"/"none"/"rc2-cfb"/"rc4-md5"/"rc4"/"salsa20"/"seed-cfb"/"xchacha20-ietf-poly1305"/"2022-blake3-aes-128-gcm"/"2022-blake3-aes-256-gcm"); -ws = comma "ws" equals flag:bool { obfs.type = "ws"; } +ws = comma "ws" equals flag:bool { if (flag) obfs.type = "ws"; } ws_headers = comma "ws-headers" equals headers:$[^,]+ { const pairs = headers.split("|"); const result = {}; diff --git a/packages/core/src/parser/protocols/simple-proxy.test.ts b/packages/core/src/parser/protocols/simple-proxy.test.ts index 489e131..e1987ca 100644 --- a/packages/core/src/parser/protocols/simple-proxy.test.ts +++ b/packages/core/src/parser/protocols/simple-proxy.test.ts @@ -15,6 +15,7 @@ describe("simple proxy parsers", () => { const colonAuth = parseHttp("http://colon.example.com:8080:user:p%40ss#Colon"); const headerAlias = parseHttp("http://alias.example.com:8080?header=bad|:skip|X-Empty:&skip_cert_verify=no&peer=peer.example.com"); const ipv6 = parseHttp("http://[2001:db8::1]:8080#IPv6"); + const ipv6DefaultPort = parseHttp("http://[2001:db8::2]"); const tlsVerified = parseHttp("http://verified.example.com:8080?tls-verification=true#Verified"); expect(http).toMatchObject({ @@ -67,6 +68,10 @@ describe("simple proxy parsers", () => { server: "2001:db8::1", port: 8080, }); + expect(ipv6DefaultPort).toMatchObject({ + server: "2001:db8::2", + port: 80, + }); expect(tlsVerified).toMatchObject({ name: "Verified", "skip-cert-verify": false, diff --git a/packages/core/src/parser/protocols/simple-proxy.ts b/packages/core/src/parser/protocols/simple-proxy.ts index 0ed34e8..1977f09 100644 --- a/packages/core/src/parser/protocols/simple-proxy.ts +++ b/packages/core/src/parser/protocols/simple-proxy.ts @@ -42,7 +42,10 @@ function stripMetaSuffix(input: string): { value: string; name?: string } { } const bracket = takeTerminalEnclosedValue(remaining, "[", "]"); - if (bracket.enclosed !== undefined) { + const bracketIsIpv6Host = + bracket.enclosed?.includes(":") && + (remaining.startsWith("[") || /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^/?#@]+@)?\[[^\]]+\]$/.test(remaining)); + if (bracket.enclosed !== undefined && !bracketIsIpv6Host) { remaining = bracket.value; } @@ -531,4 +534,3 @@ export function parseTelegramProxyLink(uri: string): SocksNode | HttpNode { return buildNode(kind, { name, server, port, username, password }) as HttpNode; } - diff --git a/packages/core/src/parser/protocols/ssr-snell.test.ts b/packages/core/src/parser/protocols/ssr-snell.test.ts index 6515052..9b27b01 100644 --- a/packages/core/src/parser/protocols/ssr-snell.test.ts +++ b/packages/core/src/parser/protocols/ssr-snell.test.ts @@ -74,6 +74,19 @@ describe("SSR and Snell parsers", () => { }); }); + it("preserves literal plus and percent sequences after SSR base64 decoding", () => { + const literal = "A+B%20C"; + const node = parseSSR( + `ssr://${b64(`literal.example.com:8388:origin:aes-256-cfb:plain:${b64("secret")}/?remarks=${b64(literal)}&protoparam=${b64(literal)}&obfsparam=${b64(literal)}`)}` + ); + + expect(node).toMatchObject({ + name: literal, + "protocol-param": literal, + "obfs-param": literal, + }); + }); + it("keeps SSR validation errors explicit", () => { expect(() => parseSSR("http://bad")).toThrow("无效的 SSR 链接"); expect(() => parseSSR("ssr://")).toThrow("无效的 SSR 链接"); diff --git a/packages/core/src/parser/protocols/ssr.ts b/packages/core/src/parser/protocols/ssr.ts index 686db20..4e8f46a 100644 --- a/packages/core/src/parser/protocols/ssr.ts +++ b/packages/core/src/parser/protocols/ssr.ts @@ -98,7 +98,7 @@ export function parseSSR(uri: string): SSRNode { const params = parseSsrQuery(query); const remarks = safeDecodeSsrBase64(params.remarks || params.remark || ""); - const name = remarks ? safeDecodeFormUrlEncoded(remarks) : `SSR-${server}:${port}`; + const name = remarks || `SSR-${server}:${port}`; const node: SSRNode = { name, @@ -114,14 +114,13 @@ export function parseSSR(uri: string): SSRNode { const protoparam = safeDecodeSsrBase64(params.protoparam || params["protocol-param"] || ""); if (protoparam) { - node["protocol-param"] = safeDecodeFormUrlEncoded(protoparam); + node["protocol-param"] = protoparam; } const obfsparam = safeDecodeSsrBase64(params.obfsparam || params["obfs-param"] || ""); if (obfsparam) { - node["obfs-param"] = safeDecodeFormUrlEncoded(obfsparam); + node["obfs-param"] = obfsparam; } return node; } - diff --git a/packages/core/src/proxy-group-targets.test.ts b/packages/core/src/proxy-group-targets.test.ts index 27b51b3..8bf1c05 100644 --- a/packages/core/src/proxy-group-targets.test.ts +++ b/packages/core/src/proxy-group-targets.test.ts @@ -42,6 +42,9 @@ describe("proxy group target helpers", () => { expect(resolveProxyGroupTargetName({ kind: "custom", id: "media" }, options)).toBe("Media"); expect(resolveProxyGroupTargetName({ kind: "custom", id: "missing" }, options)).toBe("DIRECT"); expect(resolveProxyGroupTargetName({ kind: "module", id: "missing" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "toString" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "constructor" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "hasOwnProperty" }, options)).toBe("DIRECT"); expect(resolveProxyGroupTargetName({ kind: "node" } as never, options)).toBe("DIRECT"); expect(ruleTargetMatchesName(" Proxy ", "Proxy")).toBe(true); expect(ruleTargetMatchesName({ kind: "module", id: "auto" }, "Auto")).toBe(false); diff --git a/packages/core/src/proxy-group-targets.ts b/packages/core/src/proxy-group-targets.ts index 3ca3f6d..862f88c 100644 --- a/packages/core/src/proxy-group-targets.ts +++ b/packages/core/src/proxy-group-targets.ts @@ -55,7 +55,10 @@ export function resolveProxyGroupTargetName( if (!normalized) return options.fallbackTarget || ""; if (normalized.kind === "module") { - return options.moduleNames[normalized.id]?.trim() || options.fallbackTarget || ""; + const moduleName = Object.prototype.hasOwnProperty.call(options.moduleNames, normalized.id) + ? options.moduleNames[normalized.id] + : undefined; + return (typeof moduleName === "string" ? moduleName.trim() : "") || options.fallbackTarget || ""; } const customName = (options.customProxyGroups || []).find((group) => group.id === normalized.id)?.name?.trim(); diff --git a/packages/core/src/rules-database.test.ts b/packages/core/src/rules-database.test.ts index 35863be..976a3e7 100644 --- a/packages/core/src/rules-database.test.ts +++ b/packages/core/src/rules-database.test.ts @@ -5,12 +5,6 @@ import { GEOSITE_RULES, RULE_CATEGORIES, TOTAL_RULES_COUNT, - createCustomRule, - generateRuleUrl, - getRuleById, - getRulesByCategory, - isValidRuleUrl, - searchRules, } from "./rules-database"; describe("rules database index", () => { @@ -28,75 +22,4 @@ describe("rules database index", () => { format: "mrs", }); }); - - it("searches by id, English name, Chinese display name, and ignores empty input", () => { - expect(searchRules(" openai ")).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "openai", - category: "ai", - }), - ]) - ); - expect(searchRules("TELEGRAM")).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "telegram", - category: "social", - }), - ]) - ); - expect(searchRules("电报")).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "telegram", - }), - ]) - ); - expect(searchRules(" ")).toEqual([]); - }); - - it("finds rules by category and exact id", () => { - const aiRules = getRulesByCategory("ai"); - - expect(aiRules.length).toBeGreaterThan(0); - expect(aiRules.every((rule) => rule.category === "ai")).toBe(true); - expect(getRuleById("netflix")).toMatchObject({ - nameZh: "奈飞", - category: "media", - }); - expect(getRuleById("missing-rule")).toBeUndefined(); - }); - - it("generates and validates rule URLs for built-in and custom sources", () => { - expect(generateRuleUrl("openai")).toMatch(/\/geosite\/openai\.mrs$/); - expect(generateRuleUrl("telegram", "geoip")).toMatch(/\/geoip\/telegram\.mrs$/); - - expect( - createCustomRule("custom-domain", "custom-domain", "自定义域名", "domain") - ).toMatchObject({ - id: "custom-domain", - category: "other", - behavior: "domain", - url: expect.stringMatching(/\/geosite\/custom-domain\.mrs$/), - }); - expect( - createCustomRule( - "custom-ip", - "custom-ip", - "自定义 IP", - "ipcidr", - "https://raw.githubusercontent.com/example/rules/custom-ip.mrs" - ) - ).toMatchObject({ - id: "custom-ip", - behavior: "ipcidr", - url: "https://raw.githubusercontent.com/example/rules/custom-ip.mrs", - }); - - expect(isValidRuleUrl(generateRuleUrl("openai"))).toBe(true); - expect(isValidRuleUrl("https://raw.githubusercontent.com/example/rules/list.mrs")).toBe(true); - expect(isValidRuleUrl("https://cdn.jsdelivr.net/gh/example/rules/list.mrs")).toBe(true); - expect(isValidRuleUrl("https://example.com/list.mrs")).toBe(false); - }); }); diff --git a/packages/core/src/rules-database.ts b/packages/core/src/rules-database.ts index 0d89898..7dc0ee7 100644 --- a/packages/core/src/rules-database.ts +++ b/packages/core/src/rules-database.ts @@ -397,72 +397,5 @@ export const GEOIP_RULES: RuleSetInfo[] = GEOIP_RULES_RAW; // 合并所有规则 export const ALL_RULES: RuleSetInfo[] = [...GEOSITE_RULES, ...GEOIP_RULES]; -/** - * 搜索规则集(支持中英文) - */ -export function searchRules(keyword: string): RuleSetInfo[] { - const lowerKeyword = keyword.toLowerCase().trim(); - if (!lowerKeyword) return []; - - return ALL_RULES.filter( - (rule) => - rule.id.toLowerCase().includes(lowerKeyword) || - rule.name.toLowerCase().includes(lowerKeyword) || - rule.nameZh.toLowerCase().includes(keyword.toLowerCase()) - ); -} - -/** - * 按分类获取规则 - */ -export function getRulesByCategory(category: RuleSetInfo["category"]): RuleSetInfo[] { - return ALL_RULES.filter((rule) => rule.category === category); -} - -/** - * 获取规则详情 - */ -export function getRuleById(id: string): RuleSetInfo | undefined { - return ALL_RULES.find((rule) => rule.id === id); -} - -/** - * 根据规则名称生成 URL(用于用户输入自定义规则名) - */ -export function generateRuleUrl(ruleName: string, type: "geosite" | "geoip" = "geosite"): string { - return `${BASE_URL}/${type}/${ruleName}.mrs`; -} - -/** - * 创建自定义规则 - */ -export function createCustomRule( - id: string, - name: string, - nameZh: string, - behavior: "domain" | "ipcidr", - url?: string -): RuleSetInfo { - const type = behavior === "ipcidr" ? "geoip" : "geosite"; - return { - id, - name, - nameZh, - category: "other", - behavior, - format: "mrs", - url: url || generateRuleUrl(name, type), - }; -} - -/** - * 验证规则 URL 是否有效 - */ -export function isValidRuleUrl(url: string): boolean { - return url.startsWith(BASE_URL) || - url.startsWith("https://raw.githubusercontent.com/") || - url.startsWith("https://cdn.jsdelivr.net/"); -} - // 导出规则总数 export const TOTAL_RULES_COUNT = ALL_RULES.length; diff --git a/packages/core/src/rules/custom-rule-helpers.test.ts b/packages/core/src/rules/custom-rule-helpers.test.ts index 76a626a..c4fbce6 100644 --- a/packages/core/src/rules/custom-rule-helpers.test.ts +++ b/packages/core/src/rules/custom-rule-helpers.test.ts @@ -15,7 +15,6 @@ import { getCustomRuleOrderKey, isCustomRuleType, listEditableRuleOrderKeys, - reconcileRuleOrder, } from "./custom-rule-utils"; import type { CustomProxyGroup, CustomRule, CustomRuleSet } from "@subboost/core/types/config"; @@ -29,7 +28,7 @@ describe("custom routing rule set helpers", () => { expect(parseRuleSetTargetValue("other:select")).toBeNull(); expect(extractRuleSetPathFromUrl("https://cdn.example/rules/geosite/openai.mrs?token=1")).toBe( - "geosite/openai.mrs" + "https://cdn.example/rules/geosite/openai.mrs?token=1" ); expect(extractRuleSetPathFromUrl("plain/rule.txt")).toBe("plain/rule.txt"); expect(normalizeRuleSetPathInput(" /geoip/cn.mrs ")).toBe("geoip/cn.mrs"); @@ -98,7 +97,7 @@ describe("custom routing rule set helpers", () => { source: { kind: "custom-rule-set", id: "custom-rule" }, id: "custom-rule", name: "custom-rule", - path: "geosite/custom.mrs", + path: "https://cdn.example/geosite/custom.mrs", target: expect.objectContaining({ id: "custom-a", value: "custom:custom-a" }), noResolve: true, }), @@ -153,15 +152,6 @@ describe("custom rule id and order helpers", () => { `custom-rule:${ruleWithoutId.id}`, "custom-rule-set:rule-a", ]); - expect(reconcileRuleOrder(undefined, [], [])).toEqual([]); - expect( - reconcileRuleOrder( - [" missing ", "custom-rule-set:rule-a", "custom-rule-set:rule-a"], - customRules, - customRuleSets - ) - ).toEqual(["custom-rule-set:rule-a", `custom-rule:${ruleWithoutId.id}`]); - expect(reconcileRuleOrder("bad" as never, customRules, [])).toEqual([`custom-rule:${ruleWithoutId.id}`]); }); }); diff --git a/packages/core/src/rules/custom-rule-utils.ts b/packages/core/src/rules/custom-rule-utils.ts index ad17ddd..0dc3527 100644 --- a/packages/core/src/rules/custom-rule-utils.ts +++ b/packages/core/src/rules/custom-rule-utils.ts @@ -93,33 +93,3 @@ export function listEditableRuleOrderKeys(customRules: CustomRule[], customRuleS } return keys; } - -export function reconcileRuleOrder( - ruleOrder: string[] | undefined, - customRules: CustomRule[], - customRuleSets: CustomRuleSet[] = [] -): string[] { - const available = listEditableRuleOrderKeys(customRules, customRuleSets); - if (available.length === 0) return []; - - const availableSet = new Set(available); - const next: string[] = []; - const seen = new Set(); - - if (Array.isArray(ruleOrder)) { - for (const rawKey of ruleOrder) { - const key = toTrimmedString(rawKey); - if (!key || seen.has(key) || !availableSet.has(key)) continue; - seen.add(key); - next.push(key); - } - } - - for (const key of available) { - if (seen.has(key)) continue; - seen.add(key); - next.push(key); - } - - return next; -} diff --git a/packages/core/src/rules/rule-model.test.ts b/packages/core/src/rules/rule-model.test.ts index c625390..327370b 100644 --- a/packages/core/src/rules/rule-model.test.ts +++ b/packages/core/src/rules/rule-model.test.ts @@ -10,13 +10,16 @@ import { describe("rule model normalization", () => { it("normalizes rule set paths, URLs, and builtin edits", () => { - expect(extractRuleSetPathFromUrl(" https://example.com/meta/geosite/google.mrs?download=1 ")).toBe("geosite/google.mrs"); + expect(extractRuleSetPathFromUrl(" https://example.com/meta/geosite/google.mrs?download=1 ")).toBe( + "https://example.com/meta/geosite/google.mrs?download=1" + ); expect(extractRuleSetPathFromUrl("custom/list.txt")).toBe("custom/list.txt"); expect(normalizeRuleSetPathInput("////geoip/private.mrs")).toBe("geoip/private.mrs"); expect(isValidRuleSetPathOrUrl("geosite/google.mrs")).toBe(true); expect(isValidRuleSetPathOrUrl("https://example.com/rules/list.txt")).toBe(true); expect(isValidRuleSetPathOrUrl("plain.txt")).toBe(false); expect(buildRuleSetUrlFromPath("https://cdn.example.com/custom.mrs", "https://base.example.com/")).toBe("https://cdn.example.com/custom.mrs"); + expect(normalizeRuleSetPathInput("geosite/google.mrs.bak")).toBe("geosite/google.mrs.bak"); expect(buildRuleSetUrlFromPath("/geosite/google.mrs", "https://base.example.com////")).toBe("https://base.example.com/geosite/google.mrs"); expect(normalizeBuiltinRuleEdits(null)).toEqual({}); expect( diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts index 399c74a..91a65a3 100644 --- a/packages/core/src/rules/rule-model.ts +++ b/packages/core/src/rules/rule-model.ts @@ -33,7 +33,8 @@ function trimTrailingSlashes(value: string): string { export function extractRuleSetPathFromUrl(url: string): string { const trimmed = url.trim(); - const match = trimmed.match(/(?:^|\/)(geosite|geoip)\/[^/?#\s]+\.mrs/i); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + const match = trimmed.match(/^(?:\/+)?(geosite|geoip)\/[^/?#\s]+\.mrs$/i); if (!match) return trimmed; return trimLeadingSlashes(match[0]); } diff --git a/packages/core/src/rules/rules-utils.test.ts b/packages/core/src/rules/rules-utils.test.ts index 895b4ed..01efd23 100644 --- a/packages/core/src/rules/rules-utils.test.ts +++ b/packages/core/src/rules/rules-utils.test.ts @@ -14,7 +14,6 @@ import { getCustomRuleOrderKey, isCustomRuleType, listEditableRuleOrderKeys, - reconcileRuleOrder, } from "./custom-rule-utils"; describe("custom rule helpers", () => { @@ -53,24 +52,6 @@ describe("custom rule helpers", () => { "custom-rule:custom-rule-domain-suffix-example-com-direct-3", "custom-rule-set:nested", ]); - expect( - reconcileRuleOrder( - ["missing", "custom-rule-set:nested", "custom-rule-set:nested"], - [rule], - [ - { - id: "nested", - name: "Nested", - behavior: "domain", - path: "https://rules.example.com/a.mrs", - target: "Group", - }, - ] - ) - ).toEqual([ - "custom-rule-set:nested", - "custom-rule:custom-rule-domain-suffix-example-com-direct-3", - ]); }); it("previews batch custom rule imports with ready, skipped, error, and duplicate rows", () => { diff --git a/packages/core/src/subscription/config-utils-legacy.test.ts b/packages/core/src/subscription/config-utils-legacy.test.ts new file mode 100644 index 0000000..ecfdc15 --- /dev/null +++ b/packages/core/src/subscription/config-utils-legacy.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildGenerateOptionsFromConfig } from "./config-utils"; +import type { ParsedNode } from "@subboost/core/types/node"; + +function node(patch: Partial = {}): ParsedNode { + return { + name: "Node", + type: "ss", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + "dialer-proxy": "Imported Control", + ...patch, + } as ParsedNode; +} + +describe("subscription config utils legacy and mixed persistence", () => { + it("uses legacy custom group fallback when rule model normalization returns no groups", async () => { + vi.resetModules(); + vi.doMock("@subboost/core/rules/rule-model", () => ({ + normalizeRuleModelFromConfig: () => ({ + customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, + }), + })); + + try { + const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); + const options = buildWithMockedRuleModel( + { + template: "minimal", + customProxyGroups: [ + "bad", + { id: "", name: "Bad", emoji: "B", groupType: "select" }, + { id: "select", name: " Select ", emoji: null, groupType: "select", enabled: true }, + { id: "auto", name: "Auto", emoji: "A", groupType: "url-test" }, + { id: "fallback", name: "Fallback", emoji: "F", groupType: "fallback" }, + { id: "direct", name: "Direct", emoji: "D", groupType: "direct-first" }, + { id: "reject", name: "Reject", emoji: "R", groupType: "reject-first" }, + { id: "balance", name: "Balance", emoji: "B", groupType: "load-balance", strategy: "bad" }, + ], + dialerProxyGroups: [ + { + id: "direct-dialer", + name: "Direct Dialer", + type: "direct-first", + enabled: true, + relayNodes: [" Relay ", ""], + targetNodes: "bad", + }, + { + id: "reject-dialer", + name: "Reject Dialer", + type: "reject-first", + strategy: "round-robin", + }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.customProxyGroups?.map((group) => group.groupType)).toEqual([ + "select", + "url-test", + "fallback", + "direct-first", + "reject-first", + "load-balance", + ]); + expect(options.customProxyGroups?.find((group) => group.id === "balance")).toMatchObject({ + strategy: "consistent-hashing", + }); + expect(options.dialerProxyGroups).toEqual([ + { + enabled: true, + id: "direct-dialer", + name: "Direct Dialer", + relayNodes: ["Relay"], + targetNodes: [], + type: "direct-first", + }, + { + id: "reject-dialer", + name: "Reject Dialer", + relayNodes: [], + targetNodes: [], + type: "reject-first", + }, + ]); + + const sparse = buildWithMockedRuleModel( + { + customProxyGroups: "bad", + dialerProxyGroups: "bad", + proxyGroupAdvanced: { + " missing ": "bad", + " select ": { groupType: "select" }, + " empty ": { regions: ["bad"] }, + }, + }, + { nodes: [node()] }, + ); + + expect(sparse.customProxyGroups).toBeUndefined(); + expect(sparse.dialerProxyGroups).toBeUndefined(); + expect(sparse.proxyGroupAdvanced).toEqual({ select: { groupType: "select" } }); + } finally { + vi.doUnmock("@subboost/core/rules/rule-model"); + vi.resetModules(); + } + }); + + it("normalizes legacy custom groups with dense optional field variants", async () => { + vi.resetModules(); + vi.doMock("@subboost/core/rules/rule-model", () => ({ + normalizeRuleModelFromConfig: () => ({ + customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, + }), + })); + + try { + const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); + const options = buildWithMockedRuleModel( + { + customProxyGroups: [ + { id: "missing-name", name: "", emoji: "M", groupType: "select" }, + { id: "missing-type", name: "Missing Type", emoji: "M", groupType: "invalid" }, + { + id: "select", + name: " Select ", + emoji: " S ", + enabled: false, + description: " Primary group ", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "select", + strategy: "round-robin", + advanced: { + includeRegex: "JP", + regions: ["jp"], + }, + }, + { + id: "balance", + name: "Balance", + emoji: undefined, + enabled: true, + description: " ", + memberSource: "all", + includeInGroupMembers: "yes", + groupType: "load-balance", + strategy: "round-robin", + advanced: { regions: ["bad"] }, + }, + ], + dialerProxyGroups: [ + { id: "missing-name", name: "", type: "select" }, + { id: "missing-type", name: "Missing Type", type: "invalid" }, + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + enabled: "yes", + relayNodes: [" Relay "], + targetNodes: [" Target "], + }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.customProxyGroups).toEqual([ + { + id: "select", + name: "Select", + emoji: "S", + enabled: false, + description: "Primary group", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "select", + advanced: { + includeRegex: "JP", + regions: ["jp"], + }, + }, + { + id: "balance", + name: "Balance", + emoji: "", + groupType: "load-balance", + strategy: "round-robin", + }, + ]); + expect(options.dialerProxyGroups).toEqual([ + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + strategy: "consistent-hashing", + relayNodes: ["Relay"], + targetNodes: ["Target"], + }, + ]); + } finally { + vi.doUnmock("@subboost/core/rules/rule-model"); + vi.resetModules(); + } + }); + + it("normalizes mixed persisted options without emitting empty optional sections", () => { + const options = buildGenerateOptionsFromConfig( + { + template: "full", + testUrl: " http://probe.example.com/204 ", + testInterval: 0, + enabledGroups: [1, " ", "auto", "auto", "cn"], + enabledRules: [null, "global", " "], + customRules: [ + { id: "", type: "DST-PORT", value: " 443 ", target: { kind: "module", id: " auto " } }, + { id: "bad-target", type: "DOMAIN", value: "bad.example", target: { kind: "node", name: "" } }, + { id: "src-port", type: "SRC-PORT", value: " 6881 ", target: { kind: "custom", id: " media " } }, + ], + customProxyGroups: [ + { + id: "media", + name: " Media ", + emoji: undefined, + enabled: false, + description: " Media group ", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["tw"], + includeRegex: "TW", + excludeRegex: "test", + extraMembers: [{ kind: "reject" }], + excludedMembers: [{ kind: "direct" }], + memberOrder: [{ kind: "reject" }], + }, + }, + ], + customRuleSets: [ + { + id: "manual", + name: " Manual ", + behavior: "classical", + path: "https://rules.example.com/manual.yaml", + target: { kind: "module", id: " cn " }, + noResolve: true, + }, + ], + dialerProxyGroups: [ + { + id: "direct", + name: " Direct Dialer ", + type: "direct-first", + strategy: "round-robin", + relayNodes: ["Relay", "Relay", ""], + targetNodes: ["Target", null, "Target"], + }, + ], + listenerPorts: { + socks: 65535, + negative: -1, + nan: Number.NaN, + }, + proxyGroupNameOverrides: { + cn: " China ", + }, + proxyGroupOrder: [" cn ", "cn", "", null], + mixedPort: 65535, + allowLan: false, + autoSelectStrategy: "load-balance", + cnIpNoResolve: true, + experimentalCnUseCnRuleSet: false, + }, + { nodes: [node({ name: "TW Node" })] }, + ); + + expect(options.template).toBe("full"); + expect(options.userConfig).toMatchObject({ + enabledGroups: ["auto", "auto", "cn"], + enabledRules: ["global"], + mixedPort: 65535, + allowLan: false, + autoSelectStrategy: "load-balance", + cnIpNoResolve: true, + experimentalCnUseCnRuleSet: false, + listenerPorts: { socks: 65535 }, + testUrl: "http://probe.example.com/204", + testInterval: 0, + }); + expect(options.userConfig?.customRules).toEqual([ + { + id: "custom-rule-dst-port-443-module-auto-1", + type: "DST-PORT", + value: "443", + target: { kind: "module", id: "auto" }, + }, + { + id: "src-port", + type: "SRC-PORT", + value: "6881", + target: { kind: "custom", id: "media" }, + }, + ]); + expect(options.customProxyGroups).toEqual([ + { + id: "media", + name: "Media", + emoji: "", + enabled: false, + description: "Media group", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["tw"], + includeRegex: "TW", + excludeRegex: "test", + extraMembers: [{ kind: "reject" }], + excludedMembers: [{ kind: "direct" }], + memberOrder: [{ kind: "reject" }], + }, + }, + ]); + expect(options.customRuleSets).toBeUndefined(); + expect(options.dialerProxyGroups).toEqual([ + { + id: "direct", + name: "Direct Dialer", + type: "direct-first", + relayNodes: ["Relay", "Relay"], + targetNodes: ["Target", "Target"], + }, + ]); + expect(options.proxyGroupNameOverrides).toEqual({ cn: "China" }); + expect(options.proxyGroupOrder).toEqual(["cn", "cn"]); + }); +}); diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 27bbf7a..4771a3f 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildGenerateOptionsFromConfig, getEffectiveTestOptions, } from "./config-utils"; +import { generateClashConfig } from "@subboost/core/generator"; import type { ParsedNode } from "@subboost/core/types/node"; function node(patch: Partial = {}): ParsedNode { @@ -156,6 +157,107 @@ describe("subscription config utils", () => { expect(options.proxyGroupOrder).toEqual(["auto"]); }); + it("generates from effective nodes while matching persisted original names", () => { + const kept = node({ name: "Singapore", _originName: "SG Premium" }); + const excluded = node({ name: "Pinned Hong Kong", _originName: "HK IPLC" }); + const options = buildGenerateOptionsFromConfig( + { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["^hk"], + }, + }, + { nodes: [kept, excluded] } + ); + + expect(options.nodes).toHaveLength(1); + expect(options.nodes[0]).toMatchObject({ + name: "Singapore", + _originName: "SG Premium", + }); + expect(options.nodes[0]).not.toHaveProperty("dialer-proxy"); + }); + + it("temporarily ignores filtered listener and dialer references and restores them when disabled", () => { + const nodes = [ + node({ name: "Relay", _originName: "Relay" }), + node({ name: "Target", _originName: "Target" }), + ]; + const config = { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["^target$"], + }, + listenerPorts: { + Relay: 12000, + Target: 12001, + }, + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + enabled: true, + type: "select", + relayNodes: ["Relay"], + targetNodes: ["Target"], + }, + ], + }; + + const filteredOptions = buildGenerateOptionsFromConfig(config, { nodes }); + const filtered = generateClashConfig(filteredOptions); + expect(filteredOptions.userConfig?.listenerPorts).toEqual(config.listenerPorts); + expect(filteredOptions.dialerProxyGroups?.[0]).toMatchObject({ + relayNodes: ["Relay"], + targetNodes: ["Target"], + }); + expect(filtered.proxies?.map((proxy) => proxy.name)).toEqual(["Relay"]); + expect(filtered.listeners).toEqual([ + { name: "mixed0", type: "mixed", port: 12000, proxy: "Relay" }, + ]); + expect(filtered["proxy-groups"]?.find((group) => group.name === "Chain")).toMatchObject({ + proxies: ["Relay"], + }); + + const restored = generateClashConfig( + buildGenerateOptionsFromConfig( + { + ...config, + nodeNameFilter: { + ...config.nodeNameFilter, + enabled: false, + }, + }, + { nodes } + ) + ); + expect(restored.proxies?.map((proxy) => proxy.name)).toEqual(["Relay", "Target"]); + expect(restored.proxies?.find((proxy) => proxy.name === "Target")).toMatchObject({ + "dialer-proxy": "Chain", + }); + expect(restored.listeners).toEqual([ + { name: "mixed0", type: "mixed", port: 12000, proxy: "Relay" }, + { name: "mixed1", type: "mixed", port: 12001, proxy: "Target" }, + ]); + expect(restored["proxy-groups"]?.find((group) => group.name === "Chain")).toMatchObject({ + proxies: ["Relay"], + }); + }); + + it("rejects invalid persisted node-name filters", () => { + expect(() => + buildGenerateOptionsFromConfig( + { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["(a+)+$"], + }, + }, + { nodes: [node()] } + ) + ).toThrow("节点名称过滤配置无效"); + }); + it("drops malformed persisted config while keeping safe defaults", () => { const options = buildGenerateOptionsFromConfig( { @@ -448,335 +550,4 @@ describe("subscription config utils", () => { expect(options.dialerProxyGroups).toBeUndefined(); }); - it("uses legacy custom group fallback when rule model normalization returns no groups", async () => { - vi.resetModules(); - vi.doMock("@subboost/core/rules/rule-model", () => ({ - normalizeRuleModelFromConfig: () => ({ - customProxyGroups: [], - customRuleSets: [], - builtinRuleEdits: {}, - }), - })); - - try { - const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); - const options = buildWithMockedRuleModel( - { - template: "minimal", - customProxyGroups: [ - "bad", - { id: "", name: "Bad", emoji: "B", groupType: "select" }, - { id: "select", name: " Select ", emoji: null, groupType: "select", enabled: true }, - { id: "auto", name: "Auto", emoji: "A", groupType: "url-test" }, - { id: "fallback", name: "Fallback", emoji: "F", groupType: "fallback" }, - { id: "direct", name: "Direct", emoji: "D", groupType: "direct-first" }, - { id: "reject", name: "Reject", emoji: "R", groupType: "reject-first" }, - { id: "balance", name: "Balance", emoji: "B", groupType: "load-balance", strategy: "bad" }, - ], - dialerProxyGroups: [ - { - id: "direct-dialer", - name: "Direct Dialer", - type: "direct-first", - enabled: true, - relayNodes: [" Relay ", ""], - targetNodes: "bad", - }, - { - id: "reject-dialer", - name: "Reject Dialer", - type: "reject-first", - strategy: "round-robin", - }, - ], - }, - { nodes: [node()] }, - ); - - expect(options.customProxyGroups?.map((group) => group.groupType)).toEqual([ - "select", - "url-test", - "fallback", - "direct-first", - "reject-first", - "load-balance", - ]); - expect(options.customProxyGroups?.find((group) => group.id === "balance")).toMatchObject({ - strategy: "consistent-hashing", - }); - expect(options.dialerProxyGroups).toEqual([ - { - enabled: true, - id: "direct-dialer", - name: "Direct Dialer", - relayNodes: ["Relay"], - targetNodes: [], - type: "direct-first", - }, - { - id: "reject-dialer", - name: "Reject Dialer", - relayNodes: [], - targetNodes: [], - type: "reject-first", - }, - ]); - - const sparse = buildWithMockedRuleModel( - { - customProxyGroups: "bad", - dialerProxyGroups: "bad", - proxyGroupAdvanced: { - " missing ": "bad", - " select ": { groupType: "select" }, - " empty ": { regions: ["bad"] }, - }, - }, - { nodes: [node()] }, - ); - - expect(sparse.customProxyGroups).toBeUndefined(); - expect(sparse.dialerProxyGroups).toBeUndefined(); - expect(sparse.proxyGroupAdvanced).toEqual({ select: { groupType: "select" } }); - } finally { - vi.doUnmock("@subboost/core/rules/rule-model"); - vi.resetModules(); - } - }); - - it("normalizes legacy custom groups with dense optional field variants", async () => { - vi.resetModules(); - vi.doMock("@subboost/core/rules/rule-model", () => ({ - normalizeRuleModelFromConfig: () => ({ - customProxyGroups: [], - customRuleSets: [], - builtinRuleEdits: {}, - }), - })); - - try { - const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); - const options = buildWithMockedRuleModel( - { - customProxyGroups: [ - { id: "missing-name", name: "", emoji: "M", groupType: "select" }, - { id: "missing-type", name: "Missing Type", emoji: "M", groupType: "invalid" }, - { - id: "select", - name: " Select ", - emoji: " S ", - enabled: false, - description: " Primary group ", - memberSource: "filtered-nodes", - includeInGroupMembers: false, - groupType: "select", - strategy: "round-robin", - advanced: { - includeRegex: "JP", - regions: ["jp"], - }, - }, - { - id: "balance", - name: "Balance", - emoji: undefined, - enabled: true, - description: " ", - memberSource: "all", - includeInGroupMembers: "yes", - groupType: "load-balance", - strategy: "round-robin", - advanced: { regions: ["bad"] }, - }, - ], - dialerProxyGroups: [ - { id: "missing-name", name: "", type: "select" }, - { id: "missing-type", name: "Missing Type", type: "invalid" }, - { - id: "balance-dialer", - name: "Balance Dialer", - type: "load-balance", - enabled: "yes", - relayNodes: [" Relay "], - targetNodes: [" Target "], - }, - ], - }, - { nodes: [node()] }, - ); - - expect(options.customProxyGroups).toEqual([ - { - id: "select", - name: "Select", - emoji: "S", - enabled: false, - description: "Primary group", - memberSource: "filtered-nodes", - includeInGroupMembers: false, - groupType: "select", - advanced: { - includeRegex: "JP", - regions: ["jp"], - }, - }, - { - id: "balance", - name: "Balance", - emoji: "", - groupType: "load-balance", - strategy: "round-robin", - }, - ]); - expect(options.dialerProxyGroups).toEqual([ - { - id: "balance-dialer", - name: "Balance Dialer", - type: "load-balance", - strategy: "consistent-hashing", - relayNodes: ["Relay"], - targetNodes: ["Target"], - }, - ]); - } finally { - vi.doUnmock("@subboost/core/rules/rule-model"); - vi.resetModules(); - } - }); - - it("normalizes mixed persisted options without emitting empty optional sections", () => { - const options = buildGenerateOptionsFromConfig( - { - template: "full", - testUrl: " http://probe.example.com/204 ", - testInterval: 0, - enabledGroups: [1, " ", "auto", "auto", "cn"], - enabledRules: [null, "global", " "], - customRules: [ - { id: "", type: "DST-PORT", value: " 443 ", target: { kind: "module", id: " auto " } }, - { id: "bad-target", type: "DOMAIN", value: "bad.example", target: { kind: "node", name: "" } }, - { id: "src-port", type: "SRC-PORT", value: " 6881 ", target: { kind: "custom", id: " media " } }, - ], - customProxyGroups: [ - { - id: "media", - name: " Media ", - emoji: undefined, - enabled: false, - description: " Media group ", - memberSource: "filtered-nodes", - includeInGroupMembers: true, - groupType: "load-balance", - strategy: "round-robin", - advanced: { - sourceIds: ["source-a"], - regions: ["tw"], - includeRegex: "TW", - excludeRegex: "test", - extraMembers: [{ kind: "reject" }], - excludedMembers: [{ kind: "direct" }], - memberOrder: [{ kind: "reject" }], - }, - }, - ], - customRuleSets: [ - { - id: "manual", - name: " Manual ", - behavior: "classical", - path: "https://rules.example.com/manual.yaml", - target: { kind: "module", id: " cn " }, - noResolve: true, - }, - ], - dialerProxyGroups: [ - { - id: "direct", - name: " Direct Dialer ", - type: "direct-first", - strategy: "round-robin", - relayNodes: ["Relay", "Relay", ""], - targetNodes: ["Target", null, "Target"], - }, - ], - listenerPorts: { - socks: 65535, - negative: -1, - nan: Number.NaN, - }, - proxyGroupNameOverrides: { - cn: " China ", - }, - proxyGroupOrder: [" cn ", "cn", "", null], - mixedPort: 65535, - allowLan: false, - autoSelectStrategy: "load-balance", - cnIpNoResolve: true, - experimentalCnUseCnRuleSet: false, - }, - { nodes: [node({ name: "TW Node" })] }, - ); - - expect(options.template).toBe("full"); - expect(options.userConfig).toMatchObject({ - enabledGroups: ["auto", "auto", "cn"], - enabledRules: ["global"], - mixedPort: 65535, - allowLan: false, - autoSelectStrategy: "load-balance", - cnIpNoResolve: true, - experimentalCnUseCnRuleSet: false, - listenerPorts: { socks: 65535 }, - testUrl: "http://probe.example.com/204", - testInterval: 0, - }); - expect(options.userConfig?.customRules).toEqual([ - { - id: "custom-rule-dst-port-443-module-auto-1", - type: "DST-PORT", - value: "443", - target: { kind: "module", id: "auto" }, - }, - { - id: "src-port", - type: "SRC-PORT", - value: "6881", - target: { kind: "custom", id: "media" }, - }, - ]); - expect(options.customProxyGroups).toEqual([ - { - id: "media", - name: "Media", - emoji: "", - enabled: false, - description: "Media group", - memberSource: "filtered-nodes", - includeInGroupMembers: true, - groupType: "load-balance", - strategy: "round-robin", - advanced: { - sourceIds: ["source-a"], - regions: ["tw"], - includeRegex: "TW", - excludeRegex: "test", - extraMembers: [{ kind: "reject" }], - excludedMembers: [{ kind: "direct" }], - memberOrder: [{ kind: "reject" }], - }, - }, - ]); - expect(options.customRuleSets).toBeUndefined(); - expect(options.dialerProxyGroups).toEqual([ - { - id: "direct", - name: "Direct Dialer", - type: "direct-first", - relayNodes: ["Relay", "Relay"], - targetNodes: ["Target", "Target"], - }, - ]); - expect(options.proxyGroupNameOverrides).toEqual({ cn: "China" }); - expect(options.proxyGroupOrder).toEqual(["cn", "cn"]); - }); }); diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 09d4517..e867494 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -14,6 +14,7 @@ import { } from "@subboost/core/types/config"; import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscription/imported-node-controls"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; +import { resolveNodeNameFilter } from "@subboost/core/subscription/node-name-filter"; import { ensureCustomRuleId } from "@subboost/core/rules/custom-rule-utils"; import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; @@ -298,7 +299,8 @@ export function buildGenerateOptionsFromConfig( const dialerProxyGroups = normalizeDialerProxyGroups(config.dialerProxyGroups); const proxyGroupOrder = normalizeProxyGroupOrder(config.proxyGroupOrder); - const sanitizedNodes = stripImportedNodeControlFieldsFromList(opts.nodes); + const effectiveNodes = resolveNodeNameFilter(opts.nodes, config.nodeNameFilter).effectiveNodes; + const sanitizedNodes = stripImportedNodeControlFieldsFromList(effectiveNodes); const proxyGroupAdvanced = isRecord(config.proxyGroupAdvanced) ? Object.fromEntries( Object.entries(config.proxyGroupAdvanced) diff --git a/packages/core/src/subscription/import-error.test.ts b/packages/core/src/subscription/import-error.test.ts index afab71d..e6b3ecb 100644 --- a/packages/core/src/subscription/import-error.test.ts +++ b/packages/core/src/subscription/import-error.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SubscriptionImportError, createSubscriptionImportErrorInfo, + extractHttpStatus, getSubscriptionImportErrorBadgeText, getSubscriptionImportErrorCategoryLabel, inferSubscriptionImportErrorCategory, @@ -11,6 +12,18 @@ import { sanitizePublicErrorText, } from "./import-error"; +describe("extractHttpStatus", () => { + it("only extracts explicit HTTP error status context", () => { + expect(extractHttpStatus("upstream HTTP 503 then 404")).toBe(503); + expect(extractHttpStatus("upstream HTTP: 503")).toBe(503); + expect(extractHttpStatus("request failed with status code 429")).toBe(429); + expect(extractHttpStatus("request failed with status=404")).toBe(404); + expect(extractHttpStatus("upstream returned 502")).toBe(502); + expect(extractHttpStatus("成功解析 502 个节点")).toBeNull(); + expect(extractHttpStatus("HTTP 200")).toBeNull(); + }); +}); + describe("sanitizePublicErrorText", () => { it("does not mistake protocol URIs for Windows file paths", () => { const protocols = [ diff --git a/packages/core/src/subscription/import-error.ts b/packages/core/src/subscription/import-error.ts index 2bf513f..4f73e1c 100644 --- a/packages/core/src/subscription/import-error.ts +++ b/packages/core/src/subscription/import-error.ts @@ -71,13 +71,21 @@ const NETWORK_CODE_BADGE: Record = { EAI_AGAIN: "DNS", }; -function extractHttpStatusFromText(text: string): number | null { - const match = text.match(/\b(4\d{2}|5\d{2})\b/); - if (match) { - const code = parseInt(match[1], 10); - if (code >= 400 && code < 600) return code; +export function extractHttpStatus(text: string): number | null { + const prefix = text.match( + /\b(?:HTTP(?:\/\d(?:\.\d)?)?|status(?:\s+code)?|returned|responded(?:\s+with)?)/i + ); + if (!prefix || prefix.index === undefined) return null; + + let remainder = text.slice(prefix.index + prefix[0].length).trimStart(); + if (remainder.startsWith(":") || remainder.startsWith("=")) { + remainder = remainder.slice(1).trimStart(); } - return null; + const match = remainder.match(/^(\d{3})\b/); + if (!match) return null; + + const code = Number.parseInt(match[1], 10); + return code >= 400 && code < 600 ? code : null; } function extractNetworkCodeFromText(text: string): string | null { @@ -166,7 +174,7 @@ export function getSubscriptionImportErrorBadgeText( if (info.httpStatus && HTTP_STATUS_BADGE[info.httpStatus]) { return HTTP_STATUS_BADGE[info.httpStatus]; } - const httpFromText = extractHttpStatusFromText(info.message) ?? extractHttpStatusFromText(info.detail || ""); + const httpFromText = extractHttpStatus(info.message) ?? extractHttpStatus(info.detail || ""); if (httpFromText && HTTP_STATUS_BADGE[httpFromText]) { return HTTP_STATUS_BADGE[httpFromText]; } diff --git a/packages/core/src/subscription/node-name-filter.test.ts b/packages/core/src/subscription/node-name-filter.test.ts new file mode 100644 index 0000000..49e96fa --- /dev/null +++ b/packages/core/src/subscription/node-name-filter.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedNode } from "../types/node"; +import { + NODE_NAME_FILTER_MAX_REGEXES, + NodeNameFilterConfigError, + normalizeNodeNameFilterConfig, + parseNodeNameFilterConfig, + resolveNodeNameFilter, + validateNodeNameFilterConfig, +} from "./node-name-filter"; + +function node(name: string, originName?: string): ParsedNode { + return { + name, + type: "trojan", + server: `${name.toLowerCase()}.example.com`, + port: 443, + password: "secret", + ...(originName === undefined ? {} : { _originName: originName }), + }; +} + +function unsafeNestedQuantifierPattern(): string { + const plus = String.fromCharCode(43); + return `(a${plus})${plus}$`; +} + +describe("node name filter config", () => { + it("treats a missing config as disabled and disables an empty enabled config", () => { + expect(parseNodeNameFilterConfig(undefined)).toEqual({ + enabled: false, + excludeRegexes: [], + }); + expect( + parseNodeNameFilterConfig({ + enabled: true, + excludeRegexes: ["", " "], + }) + ).toEqual({ + enabled: false, + excludeRegexes: [], + }); + }); + + it("trims rules, ignores empty lines, and deduplicates repeated lines", () => { + expect( + parseNodeNameFilterConfig({ + enabled: true, + excludeRegexes: [" hk|hong kong ", "", "hk|hong kong", " "], + }) + ).toEqual({ + enabled: true, + excludeRegexes: ["hk|hong kong"], + }); + }); + + it("keeps valid rules while the filter is disabled", () => { + expect( + parseNodeNameFilterConfig({ + enabled: false, + excludeRegexes: ["expired", "traffic"], + }) + ).toEqual({ + enabled: false, + excludeRegexes: ["expired", "traffic"], + }); + }); + + it("reports the original line for invalid, unsafe, and non-string rules", () => { + const result = validateNodeNameFilterConfig({ + enabled: true, + excludeRegexes: ["valid", 123, "[", unsafeNestedQuantifierPattern()], + }); + + expect(result).toEqual({ + ok: false, + errors: [ + { code: "invalid_line", line: 2, message: "规则必须是文本" }, + { code: "invalid_regex", line: 3, message: "正则语法无效" }, + { code: "unsafe_regex", line: 4, message: "正则可能导致运行时间过长" }, + ], + }); + }); + + it("enforces the unique rule count and per-rule length limits", () => { + const tooMany = validateNodeNameFilterConfig({ + enabled: true, + excludeRegexes: Array.from( + { length: NODE_NAME_FILTER_MAX_REGEXES + 1 }, + (_, index) => `node-${index}` + ), + }); + expect(tooMany).toMatchObject({ + ok: false, + errors: [ + { + code: "too_many_regexes", + line: NODE_NAME_FILTER_MAX_REGEXES + 1, + }, + ], + }); + + expect( + validateNodeNameFilterConfig({ + enabled: true, + excludeRegexes: ["a".repeat(201)], + }) + ).toMatchObject({ + ok: false, + errors: [{ code: "regex_too_long", line: 1 }], + }); + }); + + it("counts limits after empty-line removal and deduplication", () => { + expect( + parseNodeNameFilterConfig({ + enabled: true, + excludeRegexes: [ + "", + ...Array.from({ length: NODE_NAME_FILTER_MAX_REGEXES }, (_, index) => `rule-${index}`), + "rule-0", + " ", + ], + }).excludeRegexes + ).toHaveLength(NODE_NAME_FILTER_MAX_REGEXES); + }); + + it("strictly rejects malformed persisted config while normalize offers an explicit fallback", () => { + expect(() => parseNodeNameFilterConfig({ enabled: "yes", excludeRegexes: [] })).toThrow( + NodeNameFilterConfigError + ); + + try { + parseNodeNameFilterConfig({ + enabled: true, + excludeRegexes: ["(", unsafeNestedQuantifierPattern()], + }); + throw new Error("Expected parsing to fail"); + } catch (error) { + expect(error).toBeInstanceOf(NodeNameFilterConfigError); + expect((error as NodeNameFilterConfigError).errors).toEqual([ + { code: "invalid_regex", line: 1, message: "正则语法无效" }, + { code: "unsafe_regex", line: 2, message: "正则可能导致运行时间过长" }, + ]); + } + + expect(normalizeNodeNameFilterConfig({ enabled: "yes", excludeRegexes: [] })).toEqual({ + enabled: false, + excludeRegexes: [], + }); + }); +}); + +describe("resolveNodeNameFilter", () => { + it("matches original names case-insensitively and falls back to display names", () => { + const renamed = node("Japan Premium", "HK IPLC"); + const legacy = node("hK legacy"); + const kept = node("Singapore", "SG IEPL"); + const result = resolveNodeNameFilter( + [renamed, legacy, kept], + { + enabled: true, + excludeRegexes: ["^hk"], + } + ); + + expect(result).toEqual({ + rawNodes: [renamed, legacy, kept], + effectiveNodes: [kept], + excludedNodes: [renamed, legacy], + rawCount: 3, + excludedCount: 2, + effectiveCount: 1, + }); + }); + + it("does not let display-name edits or duplicate origin names change matching semantics", () => { + const first = node("Pinned Name", "Expired"); + const second = node("Expired (2)", "Expired"); + const renamedKeep = node("Expired", "Available"); + const result = resolveNodeNameFilter( + [first, second, renamedKeep], + { + enabled: true, + excludeRegexes: ["^expired$"], + } + ); + + expect(result.excludedNodes).toEqual([first, second]); + expect(result.effectiveNodes).toEqual([renamedKeep]); + }); + + it("returns the complete snapshot unchanged when disabled", () => { + const nodes = [node("Expired")]; + const result = resolveNodeNameFilter(nodes, { + enabled: false, + excludeRegexes: ["expired"], + }); + + expect(result.rawNodes).toBe(nodes); + expect(result.effectiveNodes).toBe(nodes); + expect(result.excludedNodes).toEqual([]); + }); + + it("rejects invalid rules instead of exposing the unfiltered node list", () => { + expect(() => + resolveNodeNameFilter([node("Node")], { + enabled: true, + excludeRegexes: [unsafeNestedQuantifierPattern()], + }) + ).toThrow(NodeNameFilterConfigError); + }); +}); diff --git a/packages/core/src/subscription/node-name-filter.ts b/packages/core/src/subscription/node-name-filter.ts new file mode 100644 index 0000000..9d2defa --- /dev/null +++ b/packages/core/src/subscription/node-name-filter.ts @@ -0,0 +1,251 @@ +import safeRegex from "safe-regex2"; +import type { ParsedNode } from "../types/node"; +import { getNodeOriginName } from "./node-source-state"; + +export const NODE_NAME_FILTER_MAX_REGEXES = 20; +export const NODE_NAME_FILTER_MAX_REGEX_LENGTH = 200; + +export type NodeNameFilterConfig = { + enabled: boolean; + excludeRegexes: string[]; +}; + +export const DEFAULT_NODE_NAME_FILTER_CONFIG: NodeNameFilterConfig = { + enabled: false, + excludeRegexes: [], +}; + +export type NodeNameFilterValidationErrorCode = + | "invalid_config" + | "invalid_line" + | "too_many_regexes" + | "regex_too_long" + | "invalid_regex" + | "unsafe_regex"; + +export type NodeNameFilterValidationError = { + code: NodeNameFilterValidationErrorCode; + message: string; + line?: number; +}; + +export type NodeNameFilterValidationResult = + | { + ok: true; + config: NodeNameFilterConfig; + } + | { + ok: false; + errors: NodeNameFilterValidationError[]; + }; + +export type NodeNameFilterResult = { + rawNodes: ParsedNode[]; + effectiveNodes: ParsedNode[]; + excludedNodes: ParsedNode[]; + rawCount: number; + excludedCount: number; + effectiveCount: number; +}; + +type ParsedNodeNameFilterConfig = { + config: NodeNameFilterConfig; + compiledRegexes: RegExp[]; +}; + +export class NodeNameFilterConfigError extends Error { + readonly errors: NodeNameFilterValidationError[]; + + constructor(errors: NodeNameFilterValidationError[]) { + const first = errors[0]; + const detail = first + ? `${first.line ? `第 ${first.line} 行:` : ""}${first.message}` + : "配置格式无效"; + super(`节点名称过滤配置无效:${detail}`); + this.name = "NodeNameFilterConfigError"; + this.errors = errors; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function defaultConfig(): NodeNameFilterConfig { + return { + enabled: DEFAULT_NODE_NAME_FILTER_CONFIG.enabled, + excludeRegexes: [], + }; +} + +function inspectNodeNameFilterConfig( + value: unknown +): + | { ok: true; parsed: ParsedNodeNameFilterConfig } + | { ok: false; errors: NodeNameFilterValidationError[] } { + if (value === undefined) { + return { + ok: true, + parsed: { + config: defaultConfig(), + compiledRegexes: [], + }, + }; + } + + if (!isRecord(value)) { + return { + ok: false, + errors: [{ code: "invalid_config", message: "配置必须是对象" }], + }; + } + + const errors: NodeNameFilterValidationError[] = []; + const enabled = value.enabled === true; + if (typeof value.enabled !== "boolean") { + errors.push({ code: "invalid_config", message: "enabled 必须是布尔值" }); + } + if (!Array.isArray(value.excludeRegexes)) { + errors.push({ code: "invalid_config", message: "excludeRegexes 必须是字符串数组" }); + } + if (errors.length > 0 || !Array.isArray(value.excludeRegexes)) { + return { ok: false, errors }; + } + + const excludeRegexes: string[] = []; + const compiledRegexes: RegExp[] = []; + const seen = new Set(); + let reportedTooMany = false; + + for (let index = 0; index < value.excludeRegexes.length; index += 1) { + const line = index + 1; + const rawPattern = value.excludeRegexes[index]; + if (typeof rawPattern !== "string") { + errors.push({ + code: "invalid_line", + line, + message: "规则必须是文本", + }); + continue; + } + + const pattern = rawPattern.trim(); + if (!pattern || seen.has(pattern)) continue; + seen.add(pattern); + + if (seen.size > NODE_NAME_FILTER_MAX_REGEXES) { + if (!reportedTooMany) { + errors.push({ + code: "too_many_regexes", + line, + message: `最多允许 ${NODE_NAME_FILTER_MAX_REGEXES} 条规则`, + }); + reportedTooMany = true; + } + continue; + } + + if (pattern.length > NODE_NAME_FILTER_MAX_REGEX_LENGTH) { + errors.push({ + code: "regex_too_long", + line, + message: `每条规则最多 ${NODE_NAME_FILTER_MAX_REGEX_LENGTH} 个字符`, + }); + continue; + } + + let compiled: RegExp; + try { + compiled = new RegExp(pattern, "i"); + } catch { + errors.push({ + code: "invalid_regex", + line, + message: "正则语法无效", + }); + continue; + } + + if (!safeRegex(compiled)) { + errors.push({ + code: "unsafe_regex", + line, + message: "正则可能导致运行时间过长", + }); + continue; + } + + excludeRegexes.push(pattern); + compiledRegexes.push(compiled); + } + + if (errors.length > 0) return { ok: false, errors }; + + return { + ok: true, + parsed: { + config: { + enabled: enabled && excludeRegexes.length > 0, + excludeRegexes, + }, + compiledRegexes, + }, + }; +} + +export function validateNodeNameFilterConfig(value: unknown): NodeNameFilterValidationResult { + const result = inspectNodeNameFilterConfig(value); + return result.ok + ? { ok: true, config: result.parsed.config } + : { ok: false, errors: result.errors }; +} + +export function parseNodeNameFilterConfig(value: unknown): NodeNameFilterConfig { + const result = inspectNodeNameFilterConfig(value); + if (!result.ok) throw new NodeNameFilterConfigError(result.errors); + return result.parsed.config; +} + +export function normalizeNodeNameFilterConfig(value: unknown): NodeNameFilterConfig { + const result = inspectNodeNameFilterConfig(value); + return result.ok ? result.parsed.config : defaultConfig(); +} + +export function resolveNodeNameFilter( + rawNodes: ParsedNode[], + configValue: unknown +): NodeNameFilterResult { + const parsed = inspectNodeNameFilterConfig(configValue); + if (!parsed.ok) throw new NodeNameFilterConfigError(parsed.errors); + + if (!parsed.parsed.config.enabled) { + return { + rawNodes, + effectiveNodes: rawNodes, + excludedNodes: [], + rawCount: rawNodes.length, + excludedCount: 0, + effectiveCount: rawNodes.length, + }; + } + + const effectiveNodes: ParsedNode[] = []; + const excludedNodes: ParsedNode[] = []; + for (const node of rawNodes) { + const originName = getNodeOriginName(node); + if (parsed.parsed.compiledRegexes.some((regex) => regex.test(originName))) { + excludedNodes.push(node); + } else { + effectiveNodes.push(node); + } + } + + return { + rawNodes, + effectiveNodes, + excludedNodes, + rawCount: rawNodes.length, + excludedCount: excludedNodes.length, + effectiveCount: effectiveNodes.length, + }; +} diff --git a/packages/core/src/subscription/subscription-userinfo.test.ts b/packages/core/src/subscription/subscription-userinfo.test.ts index 6c881cc..23b66a8 100644 --- a/packages/core/src/subscription/subscription-userinfo.test.ts +++ b/packages/core/src/subscription/subscription-userinfo.test.ts @@ -36,6 +36,12 @@ describe("subscription user info helpers", () => { }); }); + it("normalizes millisecond expiry values and rejects implausible magnitudes", () => { + expect(parseSubscriptionUserInfo("expire=1760000000000").expire).toBe(1760000000); + expect(parseSubscriptionUserInfo("expire=9999999999999999").expire).toBeUndefined(); + expect(normalizeSubscriptionUserInfo({ expire: 1760000000000 }).expire).toBe(1760000000); + }); + it("normalizes plausibility and merges traffic snapshots", () => { expect(hasSubscriptionUserInfo(null)).toBe(false); expect(hasSubscriptionUserInfo(undefined)).toBe(false); diff --git a/packages/core/src/subscription/subscription-userinfo.ts b/packages/core/src/subscription/subscription-userinfo.ts index d668e27..35f9376 100644 --- a/packages/core/src/subscription/subscription-userinfo.ts +++ b/packages/core/src/subscription/subscription-userinfo.ts @@ -17,7 +17,8 @@ export function parseSubscriptionUserInfo(header: string): SubscriptionUserInfo if (key && value) { const numeric = parseHeaderNumericValue(value); if (numeric !== undefined) { - info[key] = numeric; + const normalizedNumeric = key === "expire" ? normalizeExpireTimestampSeconds(numeric) : numeric; + if (normalizedNumeric !== undefined) info[key] = normalizedNumeric; continue; } @@ -72,9 +73,23 @@ function isFiniteNonNegativeNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } +const MIN_EXPIRE_TIMESTAMP_SECONDS = 946684800; // 2000-01-01 +const MAX_EXPIRE_TIMESTAMP_SECONDS = 253402300799; // 9999-12-31T23:59:59Z +const MIN_EXPIRE_TIMESTAMP_MILLISECONDS = MIN_EXPIRE_TIMESTAMP_SECONDS * 1000; +const MAX_EXPIRE_TIMESTAMP_MILLISECONDS = MAX_EXPIRE_TIMESTAMP_SECONDS * 1000; + +function normalizeExpireTimestampSeconds(value: unknown): number | undefined { + if (!isFiniteNonNegativeNumber(value)) return undefined; + const integer = Math.floor(value); + if (integer > MIN_EXPIRE_TIMESTAMP_SECONDS && integer <= MAX_EXPIRE_TIMESTAMP_SECONDS) return integer; + if (integer > MIN_EXPIRE_TIMESTAMP_MILLISECONDS && integer <= MAX_EXPIRE_TIMESTAMP_MILLISECONDS) { + return Math.floor(integer / 1000); + } + return undefined; +} + function isValidExpireTimestampSeconds(value: unknown): value is number { - // expire 通常为秒级 Unix 时间戳;过滤掉 0/1970 等无意义值 - return isFiniteNonNegativeNumber(value) && value > 946684800; // 2000-01-01 + return typeof value === "number" && normalizeExpireTimestampSeconds(value) === value; } function hasMeaningfulTrafficSnapshot(info: SubscriptionUserInfo): boolean { @@ -257,7 +272,8 @@ export function normalizeSubscriptionUserInfo(info: SubscriptionUserInfo | null if (isFiniteNonNegativeNumber(info.upload)) out.upload = info.upload; if (isFiniteNonNegativeNumber(info.download)) out.download = info.download; if (isFiniteNonNegativeNumber(info.total)) out.total = info.total; - if (isValidExpireTimestampSeconds(info.expire)) out.expire = info.expire; + const expire = normalizeExpireTimestampSeconds(info.expire); + if (expire !== undefined) out.expire = expire; return out; } diff --git a/packages/core/src/templates/config-template.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index a81947b..a7d8618 100644 --- a/packages/core/src/templates/config-template.fields.test.ts +++ b/packages/core/src/templates/config-template.fields.test.ts @@ -254,7 +254,7 @@ describe("validateSubBoostTemplateConfig field validation", () => { "module:cn:geolocation-cn": { target: 1 }, } as never, }, - "builtinRuleEdits.target 必须是字符串" + "builtinRuleEdits.target 必须是字符串或有效代理组引用" ); expectInvalid( { diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index 14bfc77..6bb4603 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -59,6 +59,7 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom", name: "Custom", emoji: "", + enabled: false, memberSource: "filtered-nodes", includeInGroupMembers: false, groupType: "load-balance", @@ -70,7 +71,7 @@ describe("validateSubBoostTemplateConfig", () => { name: "Custom Rule", behavior: "domain", path: "https://rules.example.com/custom.mrs", - target: "Custom", + target: { kind: "custom", id: "custom" }, noResolve: false, }, { @@ -87,7 +88,7 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom-rule-a", type: "DOMAIN-SUFFIX", value: " example.com ", - target: "DIRECT", + target: { kind: "module", id: "select" }, noResolve: true, }, ], @@ -102,7 +103,7 @@ describe("validateSubBoostTemplateConfig", () => { }, ], builtinRuleEdits: { - [`module:${moduleId}:openai`]: { enabled: false }, + [`module:${moduleId}:openai`]: { enabled: false, target: { kind: "custom", id: "custom" } }, }, proxyGroupNameOverrides: { [moduleId]: " Renamed ", @@ -120,6 +121,7 @@ describe("validateSubBoostTemplateConfig", () => { emoji: "", memberSource: "filtered-nodes", includeInGroupMembers: false, + enabled: false, groupType: "load-balance", strategy: DEFAULT_LOAD_BALANCE_STRATEGY, }); @@ -130,7 +132,7 @@ describe("validateSubBoostTemplateConfig", () => { name: "Custom Rule", behavior: "domain", path: "https://rules.example.com/custom.mrs", - target: "Custom", + target: { kind: "custom", id: "custom" }, }), expect.objectContaining({ id: "extra", @@ -145,6 +147,7 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom-rule-a", value: "example.com", noResolve: true, + target: { kind: "module", id: "select" }, }); expect(result.config.dialerProxyGroups[0]).toMatchObject({ id: "relay", @@ -153,7 +156,10 @@ describe("validateSubBoostTemplateConfig", () => { targetNodes: ["Target A"], enabled: false, }); - expect(result.config.builtinRuleEdits?.[`module:${moduleId}:openai`]).toEqual({ enabled: false }); + expect(result.config.builtinRuleEdits?.[`module:${moduleId}:openai`]).toEqual({ + enabled: false, + target: { kind: "custom", id: "custom" }, + }); expect(result.config.proxyGroupNameOverrides).toEqual({ [moduleId]: "Renamed" }); expect(result.config).not.toHaveProperty("moduleRuleOverrides"); expect(result.config).not.toHaveProperty("moduleRuleExclusions"); diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 0af2254..17547d3 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -3,6 +3,7 @@ import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRuleId, isCustomRuleType } from "@subboost/core/rules/custom-rule-utils"; import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; import { isValidRuleSetPathOrUrl, normalizeRuleModelFromConfig, @@ -15,6 +16,7 @@ import { type CustomProxyGroup, type CustomRule, type LoadBalanceStrategy, + type ProxyGroupRuleTarget, type ProxyGroupGroupType, type TemplateType, } from "@subboost/core/types/config"; @@ -185,6 +187,16 @@ function parseRequiredString( return { ok: true, value: opts.allowEmpty ? value : trimmed }; } +function parseRuleTarget( + value: unknown, + field: string +): { ok: true; value: ProxyGroupRuleTarget } | { ok: false; error: string } { + const ref = normalizeProxyGroupTargetRef(value); + if (ref) return { ok: true, value: ref }; + if (typeof value === "string") return parseRequiredString(value, field); + return invalid(`${field} 必须是字符串或有效代理组引用`); +} + function parseBoolean(value: unknown, field: string): { ok: true; value: boolean } | { ok: false; error: string } { if (typeof value !== "boolean") return invalid(`${field} 必须是布尔值`); return { ok: true, value }; @@ -289,7 +301,7 @@ function parseCustomRules(value: unknown): { ok: true; value: CustomRule[] } | { if (typeof item.type !== "string" || !isCustomRuleType(item.type)) return invalid("customRules 包含无效类型"); const ruleValue = parseRequiredString(item.value, "customRules.value"); if (!ruleValue.ok) return ruleValue; - const target = parseRequiredString(item.target, "customRules.target"); + const target = parseRuleTarget(item.target, "customRules.target"); if (!target.ok) return target; const noResolve = parseOptionalBoolean(item.noResolve, "customRules.noResolve"); if (!noResolve.ok) return noResolve; @@ -324,6 +336,8 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG if (!groupType.ok) return groupType; const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "customProxyGroups.strategy"); if (!strategy.ok) return strategy; + const enabled = parseOptionalBoolean(item.enabled, "customProxyGroups.enabled"); + if (!enabled.ok) return enabled; if (item.memberSource !== undefined && item.memberSource !== "filtered-nodes") { return invalid("customProxyGroups.memberSource 无效"); } @@ -335,6 +349,7 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG id: id.value, name: name.value, emoji: emoji.value, + ...(enabled.value !== undefined ? { enabled: enabled.value } : {}), ...(description ? { description } : {}), ...(item.memberSource === "filtered-nodes" ? { memberSource: "filtered-nodes" as const } : {}), ...(typeof item.includeInGroupMembers === "boolean" @@ -378,7 +393,7 @@ function parseCustomRuleSets(value: unknown): { ok: true; value: true } | { ok: if (!path.ok) return path; const normalizedPath = normalizeRuleSetPathInput(path.value); if (!isValidRuleSetPathOrUrl(normalizedPath)) return invalid("customRuleSets.path 无效"); - const target = parseRequiredString(item.target, "customRuleSets.target"); + const target = parseRuleTarget(item.target, "customRuleSets.target"); if (!target.ok) return target; const noResolve = parseOptionalBoolean(item.noResolve, "customRuleSets.noResolve"); if (!noResolve.ok) return noResolve; @@ -393,8 +408,9 @@ function parseBuiltinRuleEdits(value: unknown): { ok: true; value: true } | { ok const key = rawKey.trim(); if (!BUILTIN_RULE_KEYS.has(key)) return invalid("builtinRuleEdits 包含未知内置规则"); if (!isRecord(rawEdit)) return invalid("builtinRuleEdits 的值必须是对象"); - if ("target" in rawEdit && typeof rawEdit.target !== "string") { - return invalid("builtinRuleEdits.target 必须是字符串"); + if ("target" in rawEdit) { + const target = parseRuleTarget(rawEdit.target, "builtinRuleEdits.target"); + if (!target.ok) return target; } if ("enabled" in rawEdit && rawEdit.enabled !== false) { return invalid("builtinRuleEdits.enabled 只能是 false"); diff --git a/packages/server-core/src/cron-auth.test.ts b/packages/server-core/src/cron-auth.test.ts index ef22541..6ede0c2 100644 --- a/packages/server-core/src/cron-auth.test.ts +++ b/packages/server-core/src/cron-auth.test.ts @@ -19,6 +19,10 @@ describe("server-core cron auth helpers", () => { ok: false, reason: "unauthorized", }); + expect(validateCronSecret({ cronSecret: "a-much-longer-secret", authorization: "Bearer short" })).toEqual({ + ok: false, + reason: "unauthorized", + }); expect(validateCronSecret({ cronSecret: "secret", authorization: "bearer secret " })).toEqual({ ok: true, }); diff --git a/packages/server-core/src/cron-auth.ts b/packages/server-core/src/cron-auth.ts index 86a0478..19ed5a8 100644 --- a/packages/server-core/src/cron-auth.ts +++ b/packages/server-core/src/cron-auth.ts @@ -1,3 +1,5 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + export type CronSecretValidationResult = | { ok: true } | { ok: false; reason: "missing-secret" | "unauthorized" }; @@ -13,7 +15,9 @@ export function validateCronSecret(params: { authorization: string | null | undefined; }): CronSecretValidationResult { if (!params.cronSecret) return { ok: false, reason: "missing-secret" }; - if (extractBearerToken(params.authorization) !== params.cronSecret) { + const suppliedDigest = createHash("sha256").update(extractBearerToken(params.authorization)).digest(); + const expectedDigest = createHash("sha256").update(params.cronSecret).digest(); + if (!timingSafeEqual(suppliedDigest, expectedDigest)) { return { ok: false, reason: "unauthorized" }; } return { ok: true }; diff --git a/packages/server-core/src/crypto/encrypted-field.test.ts b/packages/server-core/src/crypto/encrypted-field.test.ts index 2ba4176..d0000ad 100644 --- a/packages/server-core/src/crypto/encrypted-field.test.ts +++ b/packages/server-core/src/crypto/encrypted-field.test.ts @@ -24,4 +24,32 @@ describe("encrypted field crypto", () => { expect(() => decryptEncryptedFieldV2(oldShapeCiphertext, masterKey)).toThrow("Invalid ciphertext v2 format"); }); + it("round-trips an empty plaintext", () => { + const ciphertext = encryptEncryptedFieldV2("", masterKey); + + expect(ciphertext.endsWith(":")).toBe(true); + expect(decryptEncryptedFieldV2(ciphertext, masterKey)).toBe(""); + }); + + it("rejects missing segments and invalid hex encodings", () => { + expect(() => + decryptEncryptedFieldV2( + "v2:00112233445566778899aabb:00112233445566778899aabbccddeeff", + masterKey + ) + ).toThrow("Invalid ciphertext v2 format"); + expect(() => + decryptEncryptedFieldV2( + "v2:zz112233445566778899aabb:00112233445566778899aabbccddeeff:", + masterKey + ) + ).toThrow("Invalid ciphertext v2 encoding"); + expect(() => + decryptEncryptedFieldV2( + "v2:00112233445566778899aabb:00112233445566778899aabbccddeeff:f", + masterKey + ) + ).toThrow("Invalid ciphertext v2 encoding"); + }); + }); diff --git a/packages/server-core/src/crypto/encrypted-field.ts b/packages/server-core/src/crypto/encrypted-field.ts index 7821b9c..83dc9b8 100644 --- a/packages/server-core/src/crypto/encrypted-field.ts +++ b/packages/server-core/src/crypto/encrypted-field.ts @@ -40,10 +40,18 @@ export function decryptEncryptedFieldV2(ciphertext: string, masterKey: string): const parts = ciphertext.split(":"); const [prefix, ivHex, authTagHex, encrypted] = parts; - if (prefix !== V2_PREFIX || !ivHex || !authTagHex || !encrypted || parts.length !== 4) { + if (prefix !== V2_PREFIX || !ivHex || !authTagHex || encrypted === undefined || parts.length !== 4) { throw new Error("Invalid ciphertext v2 format"); } + if ( + !/^[0-9a-f]{24}$/i.test(ivHex) + || !/^[0-9a-f]{32}$/i.test(authTagHex) + || !/^(?:[0-9a-f]{2})*$/i.test(encrypted) + ) { + throw new Error("Invalid ciphertext v2 encoding"); + } + const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); if (iv.length !== IV_LENGTH || authTag.length !== TAG_LENGTH) { diff --git a/packages/server-core/src/subscription/auto-update-failure.test.ts b/packages/server-core/src/subscription/auto-update-failure.test.ts index 44d7942..c178204 100644 --- a/packages/server-core/src/subscription/auto-update-failure.test.ts +++ b/packages/server-core/src/subscription/auto-update-failure.test.ts @@ -75,7 +75,7 @@ describe("classifyStableExternalAutoUpdateFailure", () => { it("separates stable external failures from project-side or transient failures", () => { expect(classifyStableExternalAutoUpdateFailure({ errorMessage: "当前解析任务较多" })).toEqual({ isStableExternalFailure: false, - reason: "项目侧队列或代理资源暂不可用", + reason: "服务暂时不可用", }); expect(classifyStableExternalAutoUpdateFailure({ httpStatus: 502 })).toEqual({ isStableExternalFailure: false, diff --git a/packages/server-core/src/subscription/auto-update-failure.ts b/packages/server-core/src/subscription/auto-update-failure.ts index 24e7ad2..ee72429 100644 --- a/packages/server-core/src/subscription/auto-update-failure.ts +++ b/packages/server-core/src/subscription/auto-update-failure.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { safeParseJsonObject } from "@subboost/core/json"; +import { extractHttpStatus } from "@subboost/core/subscription/import-error"; // Product-neutral failure policy shared by server adapters. export const AUTO_UPDATE_EXTERNAL_FAILURE_THRESHOLD = 3; @@ -96,13 +97,6 @@ export function serializeAutoUpdateFailureSourceState(state: AutoUpdateFailureSo return Object.keys(state).length > 0 ? JSON.stringify(state) : null; } -function extractHttpStatus(text: string): number | null { - const match = text.match(/\bHTTP\s+(\d{3})\b/i) ?? text.match(/\b(4\d{2}|5\d{2})\b/); - if (!match) return null; - const status = Number.parseInt(match[1], 10); - return Number.isFinite(status) ? status : null; -} - export function classifyStableExternalAutoUpdateFailure( source: AutoUpdateFailureSourceLike ): AutoUpdateFailureClassification { @@ -115,10 +109,9 @@ export function classifyStableExternalAutoUpdateFailure( if ( text.includes("当前解析任务较多") || - text.includes("服务暂时不可用,请稍后再试") || - text.includes("没有可用的代理服务器") + text.includes("服务暂时不可用,请稍后再试") ) { - return { isStableExternalFailure: false, reason: "项目侧队列或代理资源暂不可用" }; + return { isStableExternalFailure: false, reason: "服务暂时不可用" }; } if ( diff --git a/packages/server-core/src/subscription/crud.test.ts b/packages/server-core/src/subscription/crud.test.ts index 7339f2a..59c0a53 100644 --- a/packages/server-core/src/subscription/crud.test.ts +++ b/packages/server-core/src/subscription/crud.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NodeNameFilterConfigError } from "@subboost/core/subscription/node-name-filter"; import { areSubscriptionUrlListsEquivalent, normalizeSubscriptionConfigForPersistence, @@ -8,6 +9,7 @@ import { normalizeSubscriptionUrlList, serializeSubscriptionDetailData, serializeSubscriptionSummaryData, + validateSubscriptionNodeList, } from "./crud"; describe("subscription CRUD shared helpers", () => { @@ -29,6 +31,33 @@ describe("subscription CRUD shared helpers", () => { expect(areSubscriptionUrlListsEquivalent(["a"], ["a", "b"])).toBe(false); }); + it("strictly validates persisted node lists without blocking unknown forward-compatible types", () => { + expect( + validateSubscriptionNodeList([ + { name: "Future", type: "future-protocol", server: "future.example.com", port: 443, "dialer-proxy": "x" }, + { name: "Direct", type: "direct" }, + { name: "DNS", type: "dns" }, + { name: "Relay", type: "relay", proxies: ["Future"] }, + ]) + ).toEqual([ + { name: "Future", type: "future-protocol", server: "future.example.com", port: 443 }, + { name: "Direct", type: "direct" }, + { name: "DNS", type: "dns" }, + { name: "Relay", type: "relay", proxies: ["Future"] }, + ]); + + for (const [value, message] of [ + ["bad", "节点列表必须是数组"], + [[null], "节点 #1 必须是对象"], + [[{ name: "", type: "ss", server: "x", port: 1 }], "节点 #1 缺少有效名称"], + [[{ name: "A", type: "ss", server: "", port: 1 }], "节点 #1 缺少有效服务器地址"], + [[{ name: "A", type: "ss", server: "x", port: 0 }], "节点 #1 的端口"], + [[{ name: "A", type: "relay", proxies: [""] }], "relay proxies"], + ] as const) { + expect(() => validateSubscriptionNodeList(value)).toThrow(message); + } + }); + it("normalizes config, sources, and smart node matching state", () => { expect( normalizeSubscriptionConfigForPersistence( @@ -71,6 +100,100 @@ describe("subscription CRUD shared helpers", () => { ).toEqual({ old: true, smartNodeMatchingEnabled: true }); }); + it("strictly normalizes a persisted node name filter after config merge", () => { + expect( + normalizeSubscriptionConfigForPersistence( + { config: { theme: "light" } }, + { + existingConfig: { + keep: true, + nodeNameFilter: { + enabled: true, + excludeRegexes: [" expired ", "", "expired", "Traffic"], + }, + }, + } + ) + ).toEqual({ + keep: true, + theme: "light", + nodeNameFilter: { + enabled: true, + excludeRegexes: ["expired", "Traffic"], + }, + }); + }); + + it("keeps node name filter absent for legacy configs", () => { + const merged = normalizeSubscriptionConfigForPersistence( + { config: { theme: "light" } }, + { existingConfig: { keep: true } } + ); + expect(merged).toEqual({ keep: true, theme: "light" }); + expect(merged).not.toHaveProperty("nodeNameFilter"); + + const replaced = normalizeSubscriptionConfigForPersistence( + { config: { theme: "light" } }, + { + existingConfig: { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["expired"], + }, + }, + mergeExistingConfig: false, + } + ); + expect(replaced).toEqual({ theme: "light" }); + expect(replaced).not.toHaveProperty("nodeNameFilter"); + }); + + it("rejects invalid node name filter syntax with structured line errors", () => { + try { + normalizeSubscriptionConfigForPersistence({ + config: { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["valid", "["], + }, + }, + }); + throw new Error("Expected invalid node name filter syntax to be rejected"); + } catch (error) { + expect(error).toBeInstanceOf(NodeNameFilterConfigError); + expect((error as NodeNameFilterConfigError).errors).toEqual([ + { + code: "invalid_regex", + line: 2, + message: "正则语法无效", + }, + ]); + } + }); + + it("rejects unsafe node name filter regexes with structured line errors", () => { + try { + normalizeSubscriptionConfigForPersistence({ + config: { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["safe", "(a+)+$"], + }, + }, + }); + throw new Error("Expected unsafe node name filter regex to be rejected"); + } catch (error) { + expect(error).toBeInstanceOf(NodeNameFilterConfigError); + expect((error as NodeNameFilterConfigError).errors).toEqual([ + { + code: "unsafe_regex", + line: 2, + message: "正则可能导致运行时间过长", + }, + ]); + } + }); + it("serializes summary and detail response data", () => { const subscription = { id: "sub-1", diff --git a/packages/server-core/src/subscription/crud.ts b/packages/server-core/src/subscription/crud.ts index 142c19c..de049f7 100644 --- a/packages/server-core/src/subscription/crud.ts +++ b/packages/server-core/src/subscription/crud.ts @@ -1,4 +1,5 @@ import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscription/imported-node-controls"; +import { parseNodeNameFilterConfig } from "@subboost/core/subscription/node-name-filter"; import { normalizeSubscriptionResponseInfo } from "@subboost/core/subscription/subscription-response-info"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import type { ParsedNode } from "@subboost/core/types/node"; @@ -72,6 +73,38 @@ export function normalizeSubscriptionNodeList(value: unknown): ParsedNode[] { return Array.isArray(value) ? stripImportedNodeControlFieldsFromList(value as ParsedNode[]) : []; } +export function validateSubscriptionNodeList(value: unknown): ParsedNode[] { + if (!Array.isArray(value)) throw new Error("节点列表必须是数组"); + + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (!isRecord(item)) throw new Error(`节点 #${index + 1} 必须是对象`); + + const name = typeof item.name === "string" ? item.name.trim() : ""; + const type = typeof item.type === "string" ? item.type.trim().toLowerCase() : ""; + if (!name) throw new Error(`节点 #${index + 1} 缺少有效名称`); + if (!type) throw new Error(`节点 #${index + 1} 缺少有效类型`); + + if (type === "relay") { + if (!Array.isArray(item.proxies) || item.proxies.length === 0 || item.proxies.some((proxy) => typeof proxy !== "string" || !proxy.trim())) { + throw new Error(`节点 #${index + 1} 的 relay proxies 必须是非空字符串数组`); + } + continue; + } + + if (type === "direct" || type === "dns") continue; + + if (typeof item.server !== "string" || !item.server.trim()) { + throw new Error(`节点 #${index + 1} 缺少有效服务器地址`); + } + if (typeof item.port !== "number" || !Number.isInteger(item.port) || item.port < 1 || item.port > 65535) { + throw new Error(`节点 #${index + 1} 的端口必须是 1 到 65535 的整数`); + } + } + + return stripImportedNodeControlFieldsFromList(value as ParsedNode[]); +} + export function normalizeSubscriptionInfoForPersistence(value: unknown): Record | null { return normalizeSubscriptionResponseInfo(value); } @@ -108,6 +141,10 @@ export function normalizeSubscriptionConfigForPersistence( } } + if ("nodeNameFilter" in baseConfig) { + baseConfig.nodeNameFilter = parseNodeNameFilterConfig(baseConfig.nodeNameFilter); + } + if (typeof input.smartNodeMatchingEnabled === "boolean") { baseConfig.smartNodeMatchingEnabled = input.smartNodeMatchingEnabled; } else if (typeof baseConfig.smartNodeMatchingEnabled !== "boolean" && options.defaultSmartNodeMatchingEnabled !== undefined) { 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 c260b1b..cd3000d 100644 --- a/packages/server-core/src/subscription/refresh-cache-result.test.ts +++ b/packages/server-core/src/subscription/refresh-cache-result.test.ts @@ -79,6 +79,64 @@ describe("prepareRefreshCacheResult", () => { expect(providerOnly.nodeCount).toBe(0); expect(providerOnly.generatedYaml).toContain("proxy-providers:"); expect(providerOnly.cacheEntry.nodes).toEqual([]); + + expect( + prepareRefreshCacheResult({ + config: {}, + snapshot: snapshot({ nodes: [] }), + maxNodesPerSubscription: 10, + proxyProviders: {}, + }) + ).toMatchObject({ + ok: false, + reason: "empty_result", + }); + }); + + it("keeps the raw cache snapshot but rejects refreshes with no effective nodes", () => { + const filtered = prepareRefreshCacheResult({ + config: { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["^node-a$"], + }, + }, + snapshot: snapshot({ nodes: [node] }), + maxNodesPerSubscription: 10, + }); + + expect(filtered).toMatchObject({ + ok: false, + reason: "empty_result", + nodeCount: 1, + }); + }); + + it("allows provider output when all raw nodes are excluded and caches the raw snapshot", () => { + const filtered = prepareRefreshCacheResult({ + config: { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["^node-a$"], + }, + }, + snapshot: snapshot({ nodes: [node] }), + maxNodesPerSubscription: 10, + proxyProviders: { + remote: { + type: "http", + url: "https://provider.example.com/sub.yaml", + path: "./remote.yaml", + }, + }, + }); + + expect(filtered.ok).toBe(true); + if (!filtered.ok) return; + expect(filtered.nodeCount).toBe(1); + expect(filtered.cacheEntry.nodes).toEqual([node]); + expect(filtered.generatedYaml).toContain("proxy-providers:"); + expect(filtered.generatedYaml).not.toContain("node-a.example.com"); }); it("enforces node quota before generating YAML", () => { @@ -94,6 +152,24 @@ describe("prepareRefreshCacheResult", () => { nodeCount: 2, maxNodesPerSubscription: 1, }); + + expect( + prepareRefreshCacheResult({ + config: { + nodeNameFilter: { + enabled: true, + excludeRegexes: [".*"], + }, + }, + snapshot: snapshot({ nodes: [node, { ...node, name: "node-b" }] }), + maxNodesPerSubscription: 1, + }) + ).toMatchObject({ + ok: false, + reason: "node_quota_exceeded", + nodeCount: 2, + maxNodesPerSubscription: 1, + }); }); it("returns cache entries with generated YAML for valid snapshots", () => { @@ -125,4 +201,19 @@ describe("prepareRefreshCacheResult", () => { }, }); }); + + it("rejects invalid persisted filters before publishing refresh output", () => { + expect(() => + prepareRefreshCacheResult({ + config: { + nodeNameFilter: { + enabled: true, + excludeRegexes: ["(a+)+$"], + }, + }, + snapshot: snapshot(), + maxNodesPerSubscription: 10, + }) + ).toThrow("节点名称过滤配置无效"); + }); }); diff --git a/packages/server-core/src/subscription/refresh-cache-result.ts b/packages/server-core/src/subscription/refresh-cache-result.ts index 1225b68..433b40d 100644 --- a/packages/server-core/src/subscription/refresh-cache-result.ts +++ b/packages/server-core/src/subscription/refresh-cache-result.ts @@ -4,6 +4,7 @@ import { getEffectiveTestOptions, } from "@subboost/core/subscription/config-utils"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; +import { resolveNodeNameFilter } from "@subboost/core/subscription/node-name-filter"; 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"; @@ -48,10 +49,17 @@ export function prepareRefreshCacheResult(params: { testUrl, testInterval, }); + const hasProxyProviders = Boolean( + proxyProviders && Object.keys(proxyProviders).length > 0 + ); const common = { proxyProviders, nodeCount: params.snapshot.nodes.length, }; + const nodeNameFilterResult = resolveNodeNameFilter( + params.snapshot.nodes, + params.config.nodeNameFilter + ); if (params.snapshot.refreshableSourceCount > 0 && params.snapshot.refreshedSourceCount === 0) { return { @@ -61,19 +69,19 @@ export function prepareRefreshCacheResult(params: { }; } - if (params.snapshot.nodes.length === 0 && !proxyProviders) { + if (params.snapshot.nodes.length > params.maxNodesPerSubscription) { return { ok: false, - reason: "empty_result", + reason: "node_quota_exceeded", + maxNodesPerSubscription: params.maxNodesPerSubscription, ...common, }; } - if (params.snapshot.nodes.length > params.maxNodesPerSubscription) { + if (nodeNameFilterResult.effectiveCount === 0 && !hasProxyProviders) { return { ok: false, - reason: "node_quota_exceeded", - maxNodesPerSubscription: params.maxNodesPerSubscription, + reason: "empty_result", ...common, }; } diff --git a/packages/server-core/src/subscription/ssrf-ip.test.ts b/packages/server-core/src/subscription/ssrf-ip.test.ts index fb9b2a5..ee0d7e8 100644 --- a/packages/server-core/src/subscription/ssrf-ip.test.ts +++ b/packages/server-core/src/subscription/ssrf-ip.test.ts @@ -36,6 +36,7 @@ describe("SSRF IP classification", () => { "::", "0:0:0:0:0:0:0:0", "::1", + "0:0:0:0:0:0:0:1", "fc00::1", "fd00::1", "fe80::1", @@ -43,8 +44,20 @@ describe("SSRF IP classification", () => { "ff00::1", "2001:db8::1", "2001:0db8::1", + "100::1", + "100:0:0:1::1", + "2001:2::1", + "3fff::1", + "5f00::1", + "4000::1", "::ffff:127.0.0.1", "::ffff:c0a8:1", + "0:0:0:0:0:ffff:7f00:1", + "0:0:0:0:0:ffff:c0a8:1", + "::127.0.0.1", + "64:ff9b::127.0.0.1", + "64:ff9b:1:7f00:0:100::", + "2002:7f00:1::", ]) { expect(isPrivateOrReservedIp(ip), ip).toBe(true); } @@ -52,6 +65,11 @@ describe("SSRF IP classification", () => { expect(isPrivateOrReservedIp("2001:db80::1")).toBe(false); expect(isPrivateOrReservedIp("::ffff:8.8.8.8")).toBe(false); expect(isPrivateOrReservedIp("::ffff:808:808")).toBe(false); + expect(isPrivateOrReservedIp("::8.8.8.8")).toBe(false); + expect(isPrivateOrReservedIp("64:ff9b::8.8.8.8")).toBe(false); + expect(isPrivateOrReservedIp("2002:0808:0808::")).toBe(false); + expect(isPrivateOrReservedIp("2001:3::1")).toBe(false); + expect(isPrivateOrReservedIp("2001:4:112::1")).toBe(false); }); it("identifies the benchmark range commonly used by fake-ip DNS", () => { diff --git a/packages/server-core/src/subscription/ssrf-ip.ts b/packages/server-core/src/subscription/ssrf-ip.ts index 03e7c14..2ae5aa0 100644 --- a/packages/server-core/src/subscription/ssrf-ip.ts +++ b/packages/server-core/src/subscription/ssrf-ip.ts @@ -58,52 +58,134 @@ function isBenchmarkReservedIPv4(ip: string): boolean { return ipv4InCidr(ipInt, baseInt, 15); } -function ipv4FromMappedHexTail(value: string): string | null { - const parts = value.split(":"); - if (parts.length !== 2) return null; - const nums = parts.map((part) => Number.parseInt(part, 16)); - if (nums.some((num) => !Number.isInteger(num) || num < 0 || num > 0xffff)) return null; +function ipv4FromHextets(high: number, low: number): string { return [ - nums[0] >> 8, - nums[0] & 0xff, - nums[1] >> 8, - nums[1] & 0xff, + high >> 8, + high & 0xff, + low >> 8, + low & 0xff, ].join("."); } -function isDocumentationIPv6(ip: string): boolean { - const [first = "", second = ""] = ip.split(":"); - return Number.parseInt(first, 16) === 0x2001 && Number.parseInt(second, 16) === 0x0db8; +type Ipv6CidrRule = { + base: string; + mask: number; + unsafe: boolean; +}; + +// Ordered from the most-specific globally reachable exceptions to their +// enclosing special-purpose ranges. Keep this table aligned with the IANA +// IPv6 Special-Purpose Address Registry. +const IPV6_SPECIAL_PURPOSE_RULES: readonly Ipv6CidrRule[] = [ + { base: "2001:1::1", mask: 128, unsafe: false }, + { base: "2001:1::2", mask: 128, unsafe: false }, + { base: "2001:1::3", mask: 128, unsafe: false }, + { base: "2001:3::", mask: 32, unsafe: false }, + { base: "2001:4:112::", mask: 48, unsafe: false }, + { base: "2001:20::", mask: 28, unsafe: false }, + { base: "2001:30::", mask: 28, unsafe: false }, + { base: "::", mask: 128, unsafe: true }, + { base: "::1", mask: 128, unsafe: true }, + { base: "64:ff9b:1::", mask: 48, unsafe: true }, + { base: "100::", mask: 64, unsafe: true }, + { base: "100:0:0:1::", mask: 64, unsafe: true }, + { base: "2001::", mask: 23, unsafe: true }, + { base: "2001:db8::", mask: 32, unsafe: true }, + { base: "3fff::", mask: 20, unsafe: true }, + { base: "5f00::", mask: 16, unsafe: true }, + { base: "fc00::", mask: 7, unsafe: true }, + { base: "fe80::", mask: 10, unsafe: true }, + { base: "ff00::", mask: 8, unsafe: true }, +]; + +function expandIpv6Hextets(ip: string): number[] | null { + let normalized = ip.toLowerCase(); + if (normalized.includes(".")) { + const lastColon = normalized.lastIndexOf(":"); + if (lastColon < 0) return null; + const ipv4Int = ipv4ToInt(normalized.slice(lastColon + 1)); + if (ipv4Int === null) return null; + normalized = `${normalized.slice(0, lastColon)}:${((ipv4Int >>> 16) & 0xffff).toString(16)}:${( + ipv4Int & 0xffff + ).toString(16)}`; + } + + const compressionIndex = normalized.indexOf("::"); + if (compressionIndex !== normalized.lastIndexOf("::")) return null; + + const left = (compressionIndex === -1 ? normalized : normalized.slice(0, compressionIndex)) + .split(":") + .filter(Boolean); + const right = (compressionIndex === -1 ? "" : normalized.slice(compressionIndex + 2)) + .split(":") + .filter(Boolean); + const missing = 8 - left.length - right.length; + if ((compressionIndex === -1 && missing !== 0) || (compressionIndex !== -1 && missing < 1)) return null; + + const parts = compressionIndex === -1 ? left : [...left, ...Array(missing).fill("0"), ...right]; + const hextets = parts.map((part) => Number.parseInt(part, 16)); + if (hextets.length !== 8 || hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) { + return null; + } + return hextets; } -function isPrivateOrReservedIPv6(ip: string): boolean { - const normalized = ip.toLowerCase(); - if (normalized === "::" || normalized === "0:0:0:0:0:0:0:0") return true; - if (normalized === "::1") return true; - - const firstHextet = normalized.split(":")[0] || ""; - if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return true; - if ( - firstHextet.startsWith("fe8") || - firstHextet.startsWith("fe9") || - firstHextet.startsWith("fea") || - firstHextet.startsWith("feb") - ) { - return true; +function ipv6InCidr(hextets: readonly number[], base: string, maskBits: number): boolean { + const baseHextets = expandIpv6Hextets(base); + if (!baseHextets) return false; + const fullHextets = Math.floor(maskBits / 16); + for (let index = 0; index < fullHextets; index += 1) { + if (hextets[index] !== baseHextets[index]) return false; + } + const remainingBits = maskBits % 16; + if (remainingBits === 0) return true; + const mask = (0xffff << (16 - remainingBits)) & 0xffff; + return (hextets[fullHextets] & mask) === (baseHextets[fullHextets] & mask); +} + +function classifySpecialPurposeIpv6(hextets: readonly number[]): boolean | null { + for (const rule of IPV6_SPECIAL_PURPOSE_RULES) { + if (ipv6InCidr(hextets, rule.base, rule.mask)) return rule.unsafe; } - if (firstHextet.startsWith("ff")) return true; - if (isDocumentationIPv6(normalized)) return true; + return null; +} - const lastSegment = normalized.split(":").slice(-1)[0] || ""; - if (lastSegment.includes(".")) { - return isPrivateOrReservedIPv4(lastSegment); +function extractEmbeddedIpv4(hextets: readonly number[]): string | null { + const isIpv4Compatible = hextets.slice(0, 6).every((part) => part === 0); + const isIpv4Mapped = hextets.slice(0, 5).every((part) => part === 0) && hextets[5] === 0xffff; + const isWellKnownNat64 = hextets[0] === 0x0064 && hextets[1] === 0xff9b && hextets[2] === 0; + if (isIpv4Compatible || isIpv4Mapped || isWellKnownNat64) { + return ipv4FromHextets(hextets[6], hextets[7]); } - if (normalized.startsWith("::ffff:")) { - const mappedIpv4 = ipv4FromMappedHexTail(normalized.slice("::ffff:".length)); - return mappedIpv4 ? isPrivateOrReservedIPv4(mappedIpv4) : false; + + // RFC 6052 /48 form: v4[0..15] occupies bits 48..63, the u octet + // at bits 64..71 is zero, and v4[16..31] occupies bits 72..87. + const isLocalUseNat64 = hextets[0] === 0x0064 && hextets[1] === 0xff9b && hextets[2] === 1; + if (isLocalUseNat64 && (hextets[4] >> 8) === 0) { + const low = ((hextets[4] & 0xff) << 8) | (hextets[5] >> 8); + return ipv4FromHextets(hextets[3], low); } - return false; + if (hextets[0] === 0x2002) { + return ipv4FromHextets(hextets[1], hextets[2]); + } + + return null; +} + +function isPrivateOrReservedIPv6(ip: string): boolean { + const hextets = expandIpv6Hextets(ip); + if (!hextets) return true; + + const specialPurpose = classifySpecialPurposeIpv6(hextets); + if (specialPurpose !== null) return specialPurpose; + + const embeddedIpv4 = extractEmbeddedIpv4(hextets); + if (embeddedIpv4) return isPrivateOrReservedIPv4(embeddedIpv4); + + // IANA currently assigns globally routable IPv6 unicast space from + // 2000::/3. Other unicast blocks are reserved and must not be SSRF targets. + return !ipv6InCidr(hextets, "2000::", 3); } export function isPrivateOrReservedIp(hostname: string): boolean { diff --git a/packages/ui/package.json b/packages/ui/package.json index 395a731..316fb97 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,7 +9,6 @@ "./*": "./src/*" }, "dependencies": { - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -17,7 +16,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -26,7 +24,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^1.17.0", - "next": "^16.2.7", + "next": "^16.2.12", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", diff --git a/packages/ui/src/components/auth/user-menu.test.ts b/packages/ui/src/components/auth/user-menu.test.ts index 7c7e2d8..9a38d5b 100644 --- a/packages/ui/src/components/auth/user-menu.test.ts +++ b/packages/ui/src/components/auth/user-menu.test.ts @@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ captureAuthConfigHandoff: vi.fn(), documentHandlers: {} as Record void>, + dropdownRoots: [] as any[], + dropdownItems: [] as any[], fetchUser: vi.fn(), intrinsics: [] as any[], links: [] as any[], @@ -71,7 +73,24 @@ vi.mock("lucide-react", () => ({ })); vi.mock("@subboost/ui/components/ui/button", () => ({ - Button: (props: any) => React.createElement("button", props, props.children), + Button: ({ asChild: _asChild, ...props }: any) => React.createElement("button", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/dropdown-menu", () => ({ + DropdownMenu: (props: any) => { + mocks.dropdownRoots.push(props); + return React.createElement("div", null, props.children); + }, + DropdownMenuContent: (props: any) => React.createElement("div", props, props.children), + DropdownMenuItem: ({ asChild, children, onSelect, ...props }: any) => { + mocks.dropdownItems.push({ asChild, children, onSelect, ...props }); + return asChild + ? React.createElement(React.Fragment, null, children) + : React.createElement("button", { ...props, onClick: () => onSelect?.() }, children); + }, + DropdownMenuLabel: (props: any) => React.createElement("div", props, props.children), + DropdownMenuSeparator: (props: any) => React.createElement("hr", props), + DropdownMenuTrigger: (props: any) => React.createElement(React.Fragment, null, props.children), })); vi.mock("@subboost/ui/components/ui/safe-image", () => ({ @@ -109,6 +128,8 @@ describe("UserMenu", () => { beforeEach(() => { vi.clearAllMocks(); mocks.documentHandlers = {}; + mocks.dropdownRoots = []; + mocks.dropdownItems = []; mocks.intrinsics = []; mocks.refContains.mockReturnValue(false); vi.stubGlobal("document", { @@ -177,9 +198,11 @@ describe("UserMenu", () => { expect(html).toContain("我的订阅"); expect(html).toContain("账户设置"); expect(html).toContain("退出登录"); + expect(html).toContain('aria-label="用户菜单"'); + expect(mocks.dropdownRoots[0].open).toBe(true); }); - it("closes the menu from document, toggle, overlay, links, and logout actions", async () => { + it("delegates close behavior to DropdownMenu and logs out", async () => { mocks.stateOverride = true; mocks.userState = { fetchUser: mocks.fetchUser, @@ -199,26 +222,12 @@ describe("UserMenu", () => { renderToStaticMarkup(React.createElement(UserMenu, { privilegedMenuItem: { href: "/ops", label: "Privileged" } })); - mocks.documentHandlers.mousedown({ target: {} }); - expect(mocks.stateSetter).toHaveBeenCalledWith(false); - - mocks.stateSetter.mockClear(); - mocks.refContains.mockReturnValueOnce(true); - mocks.documentHandlers.mousedown({ target: {} }); - expect(mocks.stateSetter).not.toHaveBeenCalled(); - - findIntrinsic("button", (props) => typeof props.className === "string" && props.className.includes("px-2 py-1.5")).onClick(); - expect(mocks.stateSetter).toHaveBeenCalledWith(false); - - findIntrinsic("div", (props) => typeof props.className === "string" && props.className.includes("fixed inset-0")).onClick(); - expect(mocks.stateSetter).toHaveBeenCalledWith(false); - - for (const link of mocks.links) { - link.onClick?.(); - } + mocks.dropdownRoots[0].onOpenChange(false); expect(mocks.stateSetter).toHaveBeenCalledWith(false); - await findIntrinsic("button", (props) => textOf(props.children).includes("退出登录")).onClick(); + const logoutItem = mocks.dropdownItems.find((item) => textOf(item.children).includes("退出登录")); + expect(logoutItem).toBeTruthy(); + await logoutItem.onSelect(); expect(mocks.logout).toHaveBeenCalled(); expect(mocks.stateSetter).toHaveBeenCalledWith(false); expect(window.location.href).toBe("/"); diff --git a/packages/ui/src/components/auth/user-menu.tsx b/packages/ui/src/components/auth/user-menu.tsx index f013ddf..1341dcc 100644 --- a/packages/ui/src/components/auth/user-menu.tsx +++ b/packages/ui/src/components/auth/user-menu.tsx @@ -3,6 +3,14 @@ import * as React from "react"; import Link from "next/link"; import { Button } from "@subboost/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@subboost/ui/components/ui/dropdown-menu"; import { SafeImage } from "@subboost/ui/components/ui/safe-image"; import { captureAuthConfigHandoff } from "@subboost/ui/store/config-store/auth-handoff"; import { useConfigStore } from "@subboost/ui/store/config-store"; @@ -25,23 +33,11 @@ export type AccountMenuItem = { export function UserMenu({ privilegedMenuItem }: { privilegedMenuItem?: AccountMenuItem }) { const { user, isLoading: userLoading, fetchUser, logout: userLogout } = useUserStore(); const [isOpen, setIsOpen] = React.useState(false); - const menuRef = React.useRef(null); React.useEffect(() => { fetchUser(); }, [fetchUser]); - React.useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - const handleLogout = async () => { if (user) await userLogout(); setIsOpen(false); @@ -59,53 +55,48 @@ export function UserMenu({ privilegedMenuItem }: { privilegedMenuItem?: AccountM // 未登录 if (!user) { return ( - captureAuthConfigHandoff(useConfigStore.getState())}> - - + + ); } return ( -
-
- } - /> - {user.name || user.username} - - - - {isOpen && ( - <> -
setIsOpen(false)} + + + + + +
+ -
+ } />
@@ -128,51 +119,47 @@ export function UserMenu({ privilegedMenuItem }: { privilegedMenuItem?: AccountM {user.subscriptionCount}/{user.quota.maxSubscriptions} 订阅
-
- - {/* Menu Items */} -
- {privilegedMenuItem && user.isAdmin && !user.isBanned && ( + +
+ {privilegedMenuItem && user.isAdmin && !user.isBanned && ( + setIsOpen(false)} - className="flex items-center gap-3 px-4 py-2 text-sm text-indigo-400/80 hover:bg-white/5 hover:text-indigo-400 transition-colors" + className="flex items-center gap-3 text-sm" > {privilegedMenuItem.label} - )} + + )} + setIsOpen(false)} - className="flex items-center gap-3 px-4 py-2 text-sm text-white/60 hover:bg-white/5 hover:text-white transition-colors" + className="flex items-center gap-3 text-sm" > 我的订阅 + + setIsOpen(false)} - className="flex items-center gap-3 px-4 py-2 text-sm text-white/60 hover:bg-white/5 hover:text-white transition-colors" + className="flex items-center gap-3 text-sm" > 账户设置 -
- - {/* Logout */} -
- -
-
- - )} -
+ +
+ + void handleLogout()} + className="rounded-none px-4 py-2 text-red-400 focus:bg-white/5 focus:text-red-300" + > + + 退出登录 + + + ); } diff --git a/packages/ui/src/components/layout/header.tsx b/packages/ui/src/components/layout/header.tsx index c4a2b5a..d7c75e5 100644 --- a/packages/ui/src/components/layout/header.tsx +++ b/packages/ui/src/components/layout/header.tsx @@ -16,6 +16,7 @@ import { type LucideIcon, } from "lucide-react"; import { cn } from "@subboost/ui/lib/utils"; +import { IconButton } from "@subboost/ui/components/ui/icon-button"; import { UserMenu, type AccountMenuItem } from "@subboost/ui/components/auth/user-menu"; import { captureAuthConfigHandoff } from "@subboost/ui/store/config-store/auth-handoff"; import { useConfigStore } from "@subboost/ui/store/config-store"; @@ -189,8 +190,12 @@ export function Header({ {/* Mobile Menu Button */} - +
{/* Mobile Navigation */} {mobileMenuOpen && ( -
+