mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6)
Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike
seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth
in index.ts (clientId/redirectUri from config; store via the already-exported
createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer
AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes
playback (C3.1). Reuses the existing file token store (no token-store.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
322335dfc5
commit
8998b9623f
8 files changed
+363
-2
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { createSpotifyRouter, type SpotifyOAuthLike } from "./spotify.js";
|
||||
|
||||
type Role = "admin" | "member" | "guest";
|
||||
function makeApp(oauth: SpotifyOAuthLike, role: Role = "admin", caps: string[] = []) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Stand in for the global requireAuth that populates req.user.
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).user = { role, capabilities: new Set(caps) };
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api/spotify",
|
||||
createSpotifyRouter({
|
||||
oauth,
|
||||
logger: pino({ level: "silent" }),
|
||||
getBackendInfo: () => ({ backend: "librespot", deviceName: "TS-Bot" }),
|
||||
webUiRedirect: "/",
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
function fakeOauth(over: Partial<SpotifyOAuthLike> = {}): SpotifyOAuthLike {
|
||||
return {
|
||||
buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize?x=1", state: "st" }),
|
||||
handleCallback: async () => true,
|
||||
isAuthorized: () => false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("spotify OAuth router", () => {
|
||||
it("GET /login returns the authorize url for a permitted user", async () => {
|
||||
const app = makeApp(fakeOauth());
|
||||
const res = await request(app).get("/api/spotify/login");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toContain("accounts.spotify.com/authorize");
|
||||
});
|
||||
|
||||
it("GET /login is 403 for a member lacking platform.auth", async () => {
|
||||
const app = makeApp(fakeOauth(), "member", []);
|
||||
const res = await request(app).get("/api/spotify/login");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("GET /callback with a good code+state redirects to success", async () => {
|
||||
const handleCallback = vi.fn(async () => true);
|
||||
const app = makeApp(fakeOauth({ handleCallback }));
|
||||
const res = await request(app).get("/api/spotify/callback?code=abc&state=st");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe("/?spotify=success");
|
||||
expect(handleCallback).toHaveBeenCalledWith("abc", "st");
|
||||
});
|
||||
|
||||
it("GET /callback with a bad state (handleCallback false) redirects to error", async () => {
|
||||
const app = makeApp(fakeOauth({ handleCallback: async () => false }));
|
||||
const res = await request(app).get("/api/spotify/callback?code=abc&state=WRONG");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe("/?spotify=error");
|
||||
});
|
||||
|
||||
it("GET /callback with missing code does not call oauth and redirects to error", async () => {
|
||||
const handleCallback = vi.fn(async () => true);
|
||||
const app = makeApp(fakeOauth({ handleCallback }));
|
||||
const res = await request(app).get("/api/spotify/callback?state=st");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe("/?spotify=error");
|
||||
expect(handleCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GET /callback swallows a throwing handleCallback and redirects to error", async () => {
|
||||
const app = makeApp(fakeOauth({ handleCallback: async () => { throw new Error("boom"); } }));
|
||||
const res = await request(app).get("/api/spotify/callback?code=abc&state=st");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe("/?spotify=error");
|
||||
});
|
||||
|
||||
it("GET /status reflects authorized + backend + deviceName", async () => {
|
||||
const app = makeApp(fakeOauth({ isAuthorized: () => true }));
|
||||
const res = await request(app).get("/api/spotify/status");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authorized: true, backend: "librespot", deviceName: "TS-Bot" });
|
||||
});
|
||||
|
||||
it("GET /status is 403 for a guest", async () => {
|
||||
const app = makeApp(fakeOauth(), "guest");
|
||||
const res = await request(app).get("/api/spotify/status");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Router } from "express";
|
||||
import type { Logger } from "pino";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
|
||||
/** Minimal structural seam over SpotifyOAuth so this router needs no real
|
||||
* network/crypto in tests. The concrete SpotifyOAuth satisfies it verbatim. */
|
||||
export interface SpotifyOAuthLike {
|
||||
buildAuthorizeUrl(): { url: string; state: string };
|
||||
handleCallback(code: string, state: string): Promise<boolean>;
|
||||
isAuthorized(): boolean;
|
||||
}
|
||||
|
||||
export interface SpotifyRouterOptions {
|
||||
oauth: SpotifyOAuthLike;
|
||||
logger: Logger;
|
||||
/** Process-wide backend info for /status (single Premium account, Stage 3). */
|
||||
getBackendInfo: () => { backend: string; deviceName: string };
|
||||
/** Web UI page to bounce the browser back to after the OAuth callback. */
|
||||
webUiRedirect?: string;
|
||||
}
|
||||
|
||||
export function createSpotifyRouter(opts: SpotifyRouterOptions): Router {
|
||||
const { oauth, logger } = opts;
|
||||
const redirectBase = opts.webUiRedirect ?? "/";
|
||||
const sep = redirectBase.includes("?") ? "&" : "?";
|
||||
const router = Router();
|
||||
|
||||
// Start the Authorization Code + PKCE flow: hand the WebUI the accounts.spotify.com
|
||||
// authorize URL (verifier is stashed by state inside SpotifyOAuth). Gated like the
|
||||
// other platform logins in auth.ts.
|
||||
router.get("/login", requirePermission("platform.auth"), (_req, res) => {
|
||||
try {
|
||||
const { url } = oauth.buildAuthorizeUrl();
|
||||
res.json({ url });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Spotify authorize URL build failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth redirect target (own-app clientId => redirect_uri points here). This is a
|
||||
// top-level browser navigation carrying the SameSite=Lax session cookie, so the
|
||||
// global requireAuth passes; state is the CSRF guard for the flow itself. Always
|
||||
// redirect (never JSON) so the user lands back in the UI.
|
||||
router.get("/callback", async (req, res) => {
|
||||
const code = typeof req.query.code === "string" ? req.query.code : "";
|
||||
const state = typeof req.query.state === "string" ? req.query.state : "";
|
||||
if (!code || !state) {
|
||||
res.redirect(`${redirectBase}${sep}spotify=error`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ok = await oauth.handleCallback(code, state);
|
||||
res.redirect(`${redirectBase}${sep}spotify=${ok ? "success" : "error"}`);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Spotify OAuth callback failed");
|
||||
res.redirect(`${redirectBase}${sep}spotify=error`);
|
||||
}
|
||||
});
|
||||
|
||||
// Whether the (single, process-wide) account is authorized, plus which backend
|
||||
// + device name are configured — used by the WebUI to show login-needed state.
|
||||
router.get("/status", requireNotGuest, (_req, res) => {
|
||||
const info = opts.getBackendInfo();
|
||||
res.json({
|
||||
authorized: oauth.isAuthorized(),
|
||||
backend: info.backend,
|
||||
deviceName: info.deviceName,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { createUsersRouter } from "./api/users.js";
|
||||
import { createAuditStore } from "../data/audit.js";
|
||||
import { createAuditRouter } from "./api/audit.js";
|
||||
import { createFavoritesRouter } from "./api/favorites.js";
|
||||
import { createSpotifyRouter } from "./api/spotify.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
import { setupWebSocket } from "./websocket.js";
|
||||
import { createUserStore } from "../data/users.js";
|
||||
import { createSessionStore } from "../data/sessions.js";
|
||||
@@ -48,6 +50,9 @@ export interface WebServerOptions {
|
||||
cookieStore?: CookieStore;
|
||||
avatarStore: AvatarStore;
|
||||
staticDir?: string;
|
||||
/** Process-wide shared Spotify OAuth (single account, Stage 3). When set, the
|
||||
* /api/spotify {login,callback,status} router is mounted. */
|
||||
spotifyOAuth?: SpotifyOAuth;
|
||||
}
|
||||
|
||||
export interface WebServer {
|
||||
@@ -136,6 +141,20 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
"/api/auth",
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider)
|
||||
);
|
||||
if (options.spotifyOAuth) {
|
||||
app.use(
|
||||
"/api/spotify",
|
||||
createSpotifyRouter({
|
||||
oauth: options.spotifyOAuth,
|
||||
logger,
|
||||
getBackendInfo: () => ({
|
||||
backend: options.config.spotify.backend,
|
||||
deviceName: options.config.spotify.deviceName,
|
||||
}),
|
||||
webUiRedirect: "/",
|
||||
}),
|
||||
);
|
||||
}
|
||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
||||
|
||||
// admin-only routes
|
||||
|
||||
Reference in new issue
Block a user