From b672d76634d655c4283738a0e7b17c4fb1546803 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 3 Jul 2026 00:41:29 +0800 Subject: [PATCH] feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1] --- src/web/api/bot.test.ts | 112 +++++++++++++++++++++++++++++++++++++++- src/web/api/bot.ts | 40 +++++++++++++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index fd5de27..b11d133 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -3,7 +3,7 @@ import express from "express"; import cookieParser from "cookie-parser"; import request from "supertest"; import pino from "pino"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDatabase, type BotDatabase } from "../../data/database.js"; @@ -196,6 +196,105 @@ describe("bot router /settings", () => { expect(res.status).toBe(200); expect(config.adminGroups).toEqual([6]); }); + + it("GET /settings includes a masked spotify block (hasClientSecret, never a raw secret)", async () => { + config.spotify.enabled = true; + config.spotify.backend = "librespot"; + config.spotify.clientId = "cid-1"; + config.spotify.deviceName = "MyDevice"; + config.spotify.bitrate = 160; + config.spotify.clientSecret = "supersecret"; + + const withSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(withSecret.status).toBe(200); + expect(withSecret.body.spotify).toEqual({ + enabled: true, + backend: "librespot", + clientId: "cid-1", + deviceName: "MyDevice", + bitrate: 160, + hasClientSecret: true, + }); + // The raw secret is never serialized to the client. + expect(withSecret.body.spotify).not.toHaveProperty("clientSecret"); + + config.spotify.clientSecret = ""; + const noSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie); + expect(noSecret.body.spotify.hasClientSecret).toBe(false); + expect(noSecret.body.spotify).not.toHaveProperty("clientSecret"); + }); + + it("POST /settings updates the spotify block, echoes the masked view, and persists", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { enabled: true, backend: "librespot", clientId: "cid", deviceName: "Dev", bitrate: 160 } }); + expect(res.status).toBe(200); + expect(config.spotify.enabled).toBe(true); + expect(config.spotify.backend).toBe("librespot"); + expect(config.spotify.clientId).toBe("cid"); + expect(config.spotify.deviceName).toBe("Dev"); + expect(config.spotify.bitrate).toBe(160); + + expect(res.body.spotify).toEqual({ + enabled: true, + backend: "librespot", + clientId: "cid", + deviceName: "Dev", + bitrate: 160, + hasClientSecret: false, + }); + expect(res.body.spotify).not.toHaveProperty("clientSecret"); + + // saveConfig persisted the block to disk. + expect(existsSync(configPath)).toBe(true); + const persisted = JSON.parse(readFileSync(configPath, "utf-8")); + expect(persisted.spotify.clientId).toBe("cid"); + expect(persisted.spotify.backend).toBe("librespot"); + }); + + it("POST /settings ignores an invalid spotify backend/bitrate (partial-merge, no 400)", async () => { + config.spotify.backend = "auto"; + config.spotify.bitrate = 320; + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { backend: "bogus", bitrate: 999 } }); + expect(res.status).toBe(200); + expect(config.spotify.backend).toBe("auto"); + expect(config.spotify.bitrate).toBe(320); + }); + + it("POST /settings sets a non-empty clientSecret but a blank one never wipes it", async () => { + const set = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { clientSecret: "newsecret" } }); + expect(set.status).toBe(200); + expect(config.spotify.clientSecret).toBe("newsecret"); + expect(set.body.spotify.hasClientSecret).toBe(true); + expect(set.body.spotify).not.toHaveProperty("clientSecret"); + + const blank = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { clientSecret: "" } }); + expect(blank.status).toBe(200); + expect(config.spotify.clientSecret).toBe("newsecret"); + expect(blank.body.spotify.hasClientSecret).toBe(true); + }); + + it("POST /settings that omits spotify leaves config.spotify untouched (no regression)", async () => { + config.spotify.clientId = "keep-me"; + config.spotify.clientSecret = "keep-secret"; + const before = { ...config.spotify }; + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ autoPauseOnEmpty: false }); + expect(res.status).toBe(200); + expect(config.spotify).toEqual(before); + }); }); describe("bot router /settings guest-mode gating + persistence", () => { @@ -251,4 +350,15 @@ describe("bot router /settings guest-mode gating + persistence", () => { expect(res.body.guestMode.permissions.playNext).toBe(true); expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default }); + + it("POST /settings spotify write is 403 for a member lacking bot.manage", async () => { + const memberApp = mountBot(() => ({ role: "member", capabilities: new Set([]) })); + const res = await request(memberApp) + .post("/api/bot/settings") + .send({ spotify: { enabled: true, clientId: "cid" } }); + expect(res.status).toBe(403); + // Gate rejected before any mutation. + expect(config.spotify.enabled).toBe(false); + expect(config.spotify.clientId).toBe(""); + }); }); diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index f3ff70f..a66bcbe 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import type { BotManager } from "../../bot/manager.js"; -import type { BotConfig, GuestModeConfig } from "../../data/config.js"; +import type { BotConfig, GuestModeConfig, SpotifyConfig } from "../../data/config.js"; import { saveConfig } from "../../data/config.js"; import type { Logger } from "../../logger.js"; import type { BotDatabase } from "../../data/database.js"; @@ -20,6 +20,17 @@ export function createBotRouter( ): Router { const router = Router(); + // Masked spotify view shared by the GET response and the POST echo. The raw + // clientSecret is write-only and NEVER serialized — only whether one is stored. + const maskedSpotify = () => ({ + enabled: config.spotify.enabled, + backend: config.spotify.backend, + clientId: config.spotify.clientId, + deviceName: config.spotify.deviceName, + bitrate: config.spotify.bitrate, + hasClientSecret: config.spotify.clientSecret.length > 0, + }); + router.get("/", (req, res) => { const all = botManager.getAllBots().map((b) => b.getStatus()); const u = req.user!; @@ -39,6 +50,7 @@ export function createBotRouter( localAudioEnabled: config.localAudioEnabled, adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, + spotify: maskedSpotify(), }); }); @@ -85,6 +97,31 @@ export function createBotRouter( ); } + // Partial-merge the spotify block (mirrors config.ts validation). Invalid + // sub-fields are ignored rather than 400-ing the whole request. Omitting + // `spotify` entirely leaves config.spotify untouched. + const VALID_BACKENDS = ["auto", "go-librespot", "librespot"] as const; + const VALID_BITRATES = [96, 160, 320]; + const sp = req.body?.spotify; + if (sp && typeof sp === "object") { + const t = config.spotify; + if (typeof sp.enabled === "boolean") t.enabled = sp.enabled; + if (typeof sp.backend === "string" && (VALID_BACKENDS as readonly string[]).includes(sp.backend)) { + t.backend = sp.backend as SpotifyConfig["backend"]; + } + if (typeof sp.clientId === "string") t.clientId = sp.clientId; + // Secret is write-only + set-on-non-empty so a blank field never wipes it. + if (typeof sp.clientSecret === "string" && sp.clientSecret.length > 0) { + t.clientSecret = sp.clientSecret; + } + if (typeof sp.deviceName === "string" && sp.deviceName.trim().length > 0) { + t.deviceName = sp.deviceName.trim(); + } + if (typeof sp.bitrate === "number" && VALID_BITRATES.includes(sp.bitrate)) { + t.bitrate = sp.bitrate; + } + } + saveConfig(configPath, config); // Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a @@ -106,6 +143,7 @@ export function createBotRouter( localAudioEnabled: config.localAudioEnabled, adminGroups: config.adminGroups ?? [], guestMode: config.guestMode, + spotify: maskedSpotify(), }); });