From 1f0f162f6683a75339f7bada93d12e9bb22fb164 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 30 May 2026 13:38:57 +0800 Subject: [PATCH] feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/manager.ts | 7 ++- src/index.ts | 6 +- src/web/api/bot-list-filter.test.ts | 90 +++++++++++++++++++++++++++++ src/web/api/bot.ts | 9 ++- 4 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 src/web/api/bot-list-filter.test.ts diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 54b7b22..5748da1 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -12,6 +12,7 @@ import type { Logger } from "../logger.js"; import type { ServerProtocol } from "../ts-protocol/client.js"; import type { AvatarStore } from "../data/avatars.js"; +import type { PermissionStore } from "../data/permissions.js"; /** * Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the @@ -76,6 +77,7 @@ export class BotManager extends EventEmitter { private config: BotConfig; private logger: Logger; private avatarStore: AvatarStore; + private permissions: PermissionStore; constructor( neteaseProvider: MusicProvider, @@ -84,7 +86,8 @@ export class BotManager extends EventEmitter { database: BotDatabase, config: BotConfig, logger: Logger, - avatarStore: AvatarStore + avatarStore: AvatarStore, + permissions: PermissionStore ) { super(); this.neteaseProvider = neteaseProvider; @@ -95,6 +98,7 @@ export class BotManager extends EventEmitter { this.config = config; this.logger = logger; this.avatarStore = avatarStore; + this.permissions = permissions; } async createBot(params: CreateBotParams): Promise { @@ -152,6 +156,7 @@ export class BotManager extends EventEmitter { this.bots.delete(id); } this.database.deleteBotInstance(id); + this.permissions.pruneBot(id); this.emit("botInstanceRemoved", id); this.logger.info({ botId: id }, "Bot instance removed"); } diff --git a/src/index.ts b/src/index.ts index e107dcf..f48a557 100755 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js"; import { BiliBiliProvider } from "./music/bilibili.js"; import { createCookieStore } from "./music/auth.js"; import { createAvatarStore } from "./data/avatars.js"; +import { createPermissionStore } from "./data/permissions.js"; import { BotManager } from "./bot/manager.js"; import { createWebServer } from "./web/server.js"; @@ -56,6 +57,8 @@ async function main() { const bilibiliCookie = cookieStore.load("bilibili"); if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie); + const permissions = createPermissionStore(db.db); + const botManager = new BotManager( neteaseProvider, qqProvider, @@ -63,7 +66,8 @@ async function main() { db, config, logger, - avatarStore + avatarStore, + permissions ); await botManager.loadSavedBots(); diff --git a/src/web/api/bot-list-filter.test.ts b/src/web/api/bot-list-filter.test.ts new file mode 100644 index 0000000..84aeaab --- /dev/null +++ b/src/web/api/bot-list-filter.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createBotRouter } from "./bot.js"; + +const logger = pino({ level: "silent" }); + +// Fake bot whose getStatus() exposes its id, matching the real status shape. +function makeFakeBot(id: string) { + return { + id, + getStatus: () => ({ id }), + }; +} + +function makeBotManager() { + const b1 = makeFakeBot("b1"); + const b2 = makeFakeBot("b2"); + return { + getBot: (id: string) => (id === "b1" ? b1 : id === "b2" ? b2 : undefined), + getAllBots: () => [b1, b2], + getBotConfig: () => undefined, + createBot: async () => b1, + updateBot: () => {}, + removeBot: async () => {}, + startBot: async () => {}, + stopBot: () => {}, + } as any; +} + +function makeApp(user: any) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { (req as any).user = user; next(); }); + app.use( + "/api/bot", + createBotRouter( + makeBotManager(), + { idleTimeoutMinutes: 0 } as any, + "/tmp/config.json", + logger, + { getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any, + { read: () => null, write: () => "x", remove: () => {} } as any, + ), + ); + return app; +} + +const member = (bots: "all" | string[]) => ({ + id: "u1", + username: "alice", + role: "member" as const, + capabilities: new Set(), + bots: bots === "all" ? ("all" as const) : new Set(bots), +}); + +const admin = { + id: "a", + username: "admin", + role: "admin" as const, + capabilities: new Set(), + bots: "all" as const, +}; + +describe("GET /api/bot bot-list filtering", () => { + it("member with bots:Set([b1]) sees only b1", async () => { + const app = makeApp(member(["b1"])); + const res = await request(app).get("/api/bot"); + expect(res.status).toBe(200); + const ids = (res.body.bots as { id: string }[]).map((b) => b.id); + expect(ids).toEqual(["b1"]); + }); + + it("admin sees both b1 and b2", async () => { + const app = makeApp(admin); + const res = await request(app).get("/api/bot"); + expect(res.status).toBe(200); + const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort(); + expect(ids).toEqual(["b1", "b2"]); + }); + + it("member with bots:'all' sees both b1 and b2", async () => { + const app = makeApp(member("all")); + const res = await request(app).get("/api/bot"); + expect(res.status).toBe(200); + const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort(); + expect(ids).toEqual(["b1", "b2"]); + }); +}); diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index e22b1e3..260d10a 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -17,8 +17,13 @@ export function createBotRouter( ): Router { const router = Router(); - router.get("/", (_req, res) => { - const bots = botManager.getAllBots().map((b) => b.getStatus()); + router.get("/", (req, res) => { + const all = botManager.getAllBots().map((b) => b.getStatus()); + const u = req.user!; + const bots = + u.role === "admin" || u.bots === "all" + ? all + : all.filter((b) => u.bots instanceof Set && u.bots.has(b.id)); res.json({ bots }); });