diff --git a/src/web/api/audit.test.ts b/src/web/api/audit.test.ts index b65a658..fec16f3 100644 --- a/src/web/api/audit.test.ts +++ b/src/web/api/audit.test.ts @@ -7,6 +7,7 @@ import { createUserStore } from "../../data/users.js"; import { createSessionStore } from "../../data/sessions.js"; import { createAuditStore } from "../../data/audit.js"; import { createPermissionStore } from "../../data/permissions.js"; +import { getDefaultConfig } from "../../data/config.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; import { createAuditRouter } from "./audit.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -34,7 +35,7 @@ describe("audit router", () => { app = express(); app.use(express.json()); app.use(cookieParser()); - app.use("/api", createRequireAuth(sessions, permissions)); + app.use("/api", createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode)); app.use("/api/audit", createAuditRouter(audit)); }); diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index 3e0832f..5172567 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -60,7 +60,7 @@ describe("bot router /settings", () => { app = express(); app.use(express.json()); app.use(cookieParser()); - app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db))); + app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode)); app.use( "/api/bot", createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore), diff --git a/src/web/api/users.test.ts b/src/web/api/users.test.ts index 35e7c43..c422907 100644 --- a/src/web/api/users.test.ts +++ b/src/web/api/users.test.ts @@ -8,6 +8,7 @@ import { createUserStore, type UserStore } from "../../data/users.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js"; import { createAuditStore, type AuditStore } from "../../data/audit.js"; import { createPermissionStore, type PermissionStore } from "../../data/permissions.js"; +import { getDefaultConfig } from "../../data/config.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; import { createUsersRouter } from "./users.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -17,7 +18,7 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) { app.use(express.json()); app.use(cookieParser()); const permissions = createPermissionStore(botDb.db); - const requireAuth = createRequireAuth(sessions, permissions); + const requireAuth = createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode); const audit = createAuditStore(botDb.db); app.use("/api", requireAuth); app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions)); @@ -152,7 +153,7 @@ describe("users router", () => { const localApp = express(); localApp.use(express.json()); localApp.use(cookieParser()); - localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db))); + localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode)); localApp.use( "/api/users", createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db)) diff --git a/src/web/middleware/requireAdmin.test.ts b/src/web/middleware/requireAdmin.test.ts index df020d3..f6b25f1 100644 --- a/src/web/middleware/requireAdmin.test.ts +++ b/src/web/middleware/requireAdmin.test.ts @@ -6,6 +6,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore } from "../../data/users.js"; import { createSessionStore } from "../../data/sessions.js"; import { createPermissionStore } from "../../data/permissions.js"; +import { getDefaultConfig } from "../../data/config.js"; import { createRequireAuth } from "./requireAuth.js"; import { requireAdmin } from "./requireAdmin.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -27,7 +28,7 @@ describe("requireAdmin middleware", () => { memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`; app = express(); app.use(cookieParser()); - app.use(createRequireAuth(sessions, permissions)); + app.use(createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode)); app.use(requireAdmin); app.get("/admin-only", (_req, res) => res.json({ ok: true })); }); diff --git a/src/web/middleware/requireAuth.test.ts b/src/web/middleware/requireAuth.test.ts index fe9780f..12d9519 100644 --- a/src/web/middleware/requireAuth.test.ts +++ b/src/web/middleware/requireAuth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import express from "express"; import cookieParser from "cookie-parser"; import request from "supertest"; @@ -24,7 +24,21 @@ describe("requireAuth middleware", () => { app = express(); app.use(cookieParser()); - app.use(createRequireAuth(sessions, permissions)); + app.use( + createRequireAuth(sessions, permissions, () => ({ + enabled: true, + bots: "all", + permissions: { + addToQueue: true, + playNext: true, + playNow: true, + skip: true, + transport: true, + removeClear: true, + playMode: true, + }, + })) + ); app.get("/protected", (req, res) => { res.json({ ok: true, user: (req as any).user }); }); @@ -68,4 +82,34 @@ describe("requireAuth middleware", () => { expect(refreshed).toBeDefined(); expect(refreshed!).toMatch(/Max-Age=\d+/); }); + + // A guest session is rejected (401) when guest mode is disabled. + it("rejects a guest session when guest mode is disabled", () => { + const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) }; + const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] }; + const getGuestConfig = () => ({ enabled: false, bots: "all" as const, permissions: {} as any }); + const mw = createRequireAuth(sessions, permissions, getGuestConfig); + const req: any = { headers: { cookie: "tsmb_session=x" } }; + const res: any = { statusCode: 0, cleared: false, clearCookie() { this.cleared = true; }, status(c: number) { this.statusCode = c; return this; }, json() { return this; }, cookie() {} }; + const next = vi.fn(); + mw(req, res, next); + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("attaches guest permissions when guest mode is enabled", () => { + const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) }; + const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] }; + const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false }; + const getGuestConfig = () => ({ enabled: true, bots: ["bot1"], permissions: perms }); + const mw = createRequireAuth(sessions, permissions, getGuestConfig); + const req: any = { headers: { cookie: "tsmb_session=x" }, secure: false }; + const res: any = { status() { return this; }, json() { return this; }, cookie() {}, clearCookie() {} }; + const next = vi.fn(); + mw(req, res, next); + expect(next).toHaveBeenCalled(); + expect(req.user.role).toBe("guest"); + expect(req.user.guest.addToQueue).toBe(true); + expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true); + }); }); diff --git a/src/web/middleware/requireAuth.ts b/src/web/middleware/requireAuth.ts index 6865f21..1dc9b36 100644 --- a/src/web/middleware/requireAuth.ts +++ b/src/web/middleware/requireAuth.ts @@ -1,7 +1,8 @@ import type { Request, Response, NextFunction, RequestHandler } from "express"; import type { SessionStore } from "../../data/sessions.js"; import { SESSION_TTL_MS } from "../../data/sessions.js"; -import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js"; +import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js"; +import type { GuestModeConfig } from "../../data/config.js"; import { validateSessionFromHeaders, extractSessionToken, @@ -13,14 +14,19 @@ declare module "express-serve-static-core" { user?: { id: string; username: string; - role: "admin" | "member"; + role: "admin" | "member" | "guest"; capabilities?: Set; bots?: "all" | Set; + guest?: GuestPermissions; }; } } -export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler { +export function createRequireAuth( + sessions: SessionStore, + permissions: PermissionStore, + getGuestConfig: () => GuestModeConfig +): RequestHandler { return function requireAuth(req: Request, res: Response, next: NextFunction) { const result = validateSessionFromHeaders(req.headers.cookie, sessions); if (!result) { @@ -28,13 +34,27 @@ export function createRequireAuth(sessions: SessionStore, permissions: Permissio res.status(401).json({ error: "unauthenticated" }); return; } - const ctx = resolvePermissionContext(result.role, result.userId, permissions); + // A guest session is only valid while guest mode is enabled. Disabling it + // immediately invalidates any in-flight guest sessions. + const guestCfg = getGuestConfig(); + if (result.role === "guest" && !guestCfg.enabled) { + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + res.status(401).json({ error: "unauthenticated" }); + return; + } + const ctx = resolvePermissionContext( + result.role, + result.userId, + permissions, + result.role === "guest" ? { bots: guestCfg.bots, permissions: guestCfg.permissions } : undefined + ); req.user = { id: result.userId, username: result.username, role: result.role, capabilities: ctx.capabilities, bots: ctx.bots, + guest: ctx.guest, }; const token = extractSessionToken(req.headers.cookie); if (token) { diff --git a/src/web/server.ts b/src/web/server.ts index e89e5bf..b1d5809 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -98,7 +98,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions)); // ─── Gates for everything else under /api ─────────────────────────────── - const requireAuth = createRequireAuth(sessions, permissions); + const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode); app.use("/api", csrfOriginCheck); app.use("/api", requireAuth);