From d04093ec6eb4872221881f812289661da14baa70 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sun, 17 May 2026 00:39:29 +0700 Subject: [PATCH] feat: add admin authentication to media and voice routes for secure access --- src/routes/mediaRoutes.ts | 32 +++++++++++++++++++++++++------- src/routes/voiceRoutes.ts | 31 ++++++++++++++++++++++--------- src/webserver.ts | 19 ++++++++----------- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/routes/mediaRoutes.ts b/src/routes/mediaRoutes.ts index e693f76..b60bf4e 100644 --- a/src/routes/mediaRoutes.ts +++ b/src/routes/mediaRoutes.ts @@ -1,4 +1,4 @@ -import type { Router } from "express"; +import type { NextFunction, Request, Response, Router } from "express"; import express from "express"; import { AppError } from "../errors"; import type { MediaController } from "../media/mediaController"; @@ -9,10 +9,28 @@ export type MediaRouteController = Pick< "getState" | "queue" | "skip" | "stop" >; -export function createMediaRoutes(controller: MediaRouteController): Router { - const router = express.Router(); +export interface MediaRouteOptions { + adminPassword?: string; +} - router.get("/media/status", (_req, res, next) => { +export function createMediaRoutes( + controller: MediaRouteController, + options: MediaRouteOptions = {}, +): Router { + const router = express.Router(); + const { adminPassword } = options; + + const adminAuth = (req: Request, res: Response, next: NextFunction) => { + if (!adminPassword) return next(); + const authHeader = req.headers["x-admin-password"]; + if (authHeader === adminPassword) { + next(); + } else { + res.status(401).json({ error: "Unauthorized access to admin features" }); + } + }; + + router.get("/media/status", (_req: Request, res: Response, next: NextFunction) => { try { res.json(controller.getState()); } catch (error) { @@ -20,7 +38,7 @@ export function createMediaRoutes(controller: MediaRouteController): Router { } }); - router.post("/media/queue", async (req, res, next) => { + router.post("/media/queue", adminAuth, async (req: Request, res: Response, next: NextFunction) => { try { const { source, mode = "music" } = req.body as { source?: string; @@ -42,7 +60,7 @@ export function createMediaRoutes(controller: MediaRouteController): Router { } }); - router.post("/media/skip", async (_req, res, next) => { + router.post("/media/skip", adminAuth, async (_req: Request, res: Response, next: NextFunction) => { try { res.json(await controller.skip()); } catch (error) { @@ -50,7 +68,7 @@ export function createMediaRoutes(controller: MediaRouteController): Router { } }); - router.post("/media/stop", async (_req, res, next) => { + router.post("/media/stop", adminAuth, async (_req: Request, res: Response, next: NextFunction) => { try { res.json(await controller.stop()); } catch (error) { diff --git a/src/routes/voiceRoutes.ts b/src/routes/voiceRoutes.ts index f20280d..61b23b3 100644 --- a/src/routes/voiceRoutes.ts +++ b/src/routes/voiceRoutes.ts @@ -1,4 +1,4 @@ -import type { Router } from "express"; +import type { NextFunction, Request, Response, Router } from "express"; import express from "express"; import { AppError } from "../errors"; import { createChildLogger } from "../logger"; @@ -12,6 +12,7 @@ export interface VoiceRouteOptions { voiceController: VoiceController; patchSharedUIState: (patch: Partial) => SharedUIState; broadcaster: ModerationBroadcaster; + adminPassword?: string; } export function createVoiceRoutes( @@ -25,6 +26,7 @@ export function createVoiceRoutes( | ((patch: Partial) => SharedUIState) | undefined; let broadcaster: ModerationBroadcaster | undefined; + let adminPassword: string | undefined; if ("connect" in options && "disconnect" in options) { // Old signature: just VoiceController @@ -35,10 +37,21 @@ export function createVoiceRoutes( voiceController = opts.voiceController; patchSharedUIState = opts.patchSharedUIState; broadcaster = opts.broadcaster; + adminPassword = opts.adminPassword; } + const adminAuth = (req: Request, res: Response, next: NextFunction) => { + if (!adminPassword) return next(); + const authHeader = req.headers["x-admin-password"]; + if (authHeader === adminPassword) { + next(); + } else { + res.status(401).json({ error: "Unauthorized access to admin features" }); + } + }; + // GET /api/status - Get voice connection status - router.get("/status", (_req, res, next) => { + router.get("/status", (_req: Request, res: Response, next: NextFunction) => { try { const status = voiceController.getStatus(); res.json(status); @@ -48,7 +61,7 @@ export function createVoiceRoutes( }); // GET /api/guilds - List available guilds - router.get("/guilds", (_req, res, next) => { + router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => { try { const guilds = voiceController.listGuilds(); res.json(guilds); @@ -58,7 +71,7 @@ export function createVoiceRoutes( }); // GET /api/guilds/:guildId/voice-channels - List voice channels in a guild - router.get("/guilds/:guildId/voice-channels", async (req, res, next) => { + router.get("/guilds/:guildId/voice-channels", async (req: Request, res: Response, next: NextFunction) => { try { const { guildId } = req.params; @@ -66,7 +79,7 @@ export function createVoiceRoutes( throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); } - const channels = await voiceController.listVoiceChannels(guildId); + const channels = await voiceController.listVoiceChannels(guildId as string); res.json(channels); } catch (error) { next(error); @@ -74,7 +87,7 @@ export function createVoiceRoutes( }); // GET /api/guilds/:guildId/channels - List text channels in a guild - router.get("/guilds/:guildId/channels", async (req, res, next) => { + router.get("/guilds/:guildId/channels", async (req: Request, res: Response, next: NextFunction) => { try { const { guildId } = req.params; @@ -82,7 +95,7 @@ export function createVoiceRoutes( throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); } - const channels = await voiceController.listWatchableChannels(guildId); + const channels = await voiceController.listWatchableChannels(guildId as string); res.json(channels); } catch (error) { next(error); @@ -90,7 +103,7 @@ export function createVoiceRoutes( }); // POST /api/connect - Connect to a voice channel - router.post("/connect", async (req, res, next) => { + router.post("/connect", adminAuth, async (req: Request, res: Response, next: NextFunction) => { try { const { guildId, channelId } = req.body as { guildId?: string; @@ -125,7 +138,7 @@ export function createVoiceRoutes( }); // POST /api/disconnect - Disconnect from voice channel - router.post("/disconnect", async (_req, res, next) => { + router.post("/disconnect", adminAuth, async (_req: Request, res: Response, next: NextFunction) => { try { logger.info("Disconnecting from voice channel"); diff --git a/src/webserver.ts b/src/webserver.ts index 9410b25..98e69ad 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -183,6 +183,7 @@ export async function startWebserver( // Create broadcaster instance const broadcaster = createBroadcaster(); (globalThis as VoiceGlobals).moderationBroadcaster = broadcaster; + (globalThis as any).ADMIN_PASSWORD = config.ADMIN_PASSWORD; const streamer = new Streamer(_client); const screenController = createScreenShareController({ @@ -268,15 +269,6 @@ export async function startWebserver( } }); - const adminAuth = (req: Request, res: Response, next: NextFunction) => { - const authHeader = req.headers["x-admin-password"]; - if (authHeader === config.ADMIN_PASSWORD) { - next(); - } else { - res.status(401).json({ error: "Unauthorized access to admin features" }); - } - }; - // Register route modules app.use( "/api", @@ -284,17 +276,22 @@ export async function startWebserver( ); app.use( "/api", - adminAuth, createVoiceRoutes({ voiceController, patchSharedUIState, broadcaster, + adminPassword: config.ADMIN_PASSWORD, }), ); app.use("/api", createMessageRoutes()); app.use("/api", createAnalysisRoutes()); app.use("/api", createSyncRoutes(_client)); - app.use("/api", adminAuth, createMediaRoutes(mediaController)); + app.use( + "/api", + createMediaRoutes(mediaController, { + adminPassword: config.ADMIN_PASSWORD, + }), + ); // Inbound: Discord PCM → tagged chunks → browser (globalThis as VoiceGlobals).broadcastPcmToWeb = (