feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-05-30 13:38:57 +08:00
1 parent cd6f2c6078
commit 1f0f162f66
4 files changed
+108 -4

No files matched your search

+6 -1
View File
@@ -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<BotInstance> {
@@ -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");
}
+5 -1
View File
@@ -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();
+90
View File
@@ -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<string>(),
bots: bots === "all" ? ("all" as const) : new Set(bots),
});
const admin = {
id: "a",
username: "admin",
role: "admin" as const,
capabilities: new Set<string>(),
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"]);
});
});
+7 -2
View File
@@ -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 });
});