feat: add admin authentication to media and voice routes for secure access

This commit is contained in:
MythEclipse
2026-05-17 00:39:29 +07:00
parent 05feb697f0
commit d04093ec6e
3 changed files with 55 additions and 27 deletions

View File

@@ -1,4 +1,4 @@
import type { Router } from "express"; import type { NextFunction, Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import type { MediaController } from "../media/mediaController"; import type { MediaController } from "../media/mediaController";
@@ -9,10 +9,28 @@ export type MediaRouteController = Pick<
"getState" | "queue" | "skip" | "stop" "getState" | "queue" | "skip" | "stop"
>; >;
export function createMediaRoutes(controller: MediaRouteController): Router { export interface MediaRouteOptions {
const router = express.Router(); 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 { try {
res.json(controller.getState()); res.json(controller.getState());
} catch (error) { } 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 { try {
const { source, mode = "music" } = req.body as { const { source, mode = "music" } = req.body as {
source?: string; 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 { try {
res.json(await controller.skip()); res.json(await controller.skip());
} catch (error) { } 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 { try {
res.json(await controller.stop()); res.json(await controller.stop());
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,4 @@
import type { Router } from "express"; import type { NextFunction, Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
voiceController: VoiceController; voiceController: VoiceController;
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState; patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
broadcaster: ModerationBroadcaster; broadcaster: ModerationBroadcaster;
adminPassword?: string;
} }
export function createVoiceRoutes( export function createVoiceRoutes(
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
| ((patch: Partial<SharedUIState>) => SharedUIState) | ((patch: Partial<SharedUIState>) => SharedUIState)
| undefined; | undefined;
let broadcaster: ModerationBroadcaster | undefined; let broadcaster: ModerationBroadcaster | undefined;
let adminPassword: string | undefined;
if ("connect" in options && "disconnect" in options) { if ("connect" in options && "disconnect" in options) {
// Old signature: just VoiceController // Old signature: just VoiceController
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
voiceController = opts.voiceController; voiceController = opts.voiceController;
patchSharedUIState = opts.patchSharedUIState; patchSharedUIState = opts.patchSharedUIState;
broadcaster = opts.broadcaster; 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 // GET /api/status - Get voice connection status
router.get("/status", (_req, res, next) => { router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
try { try {
const status = voiceController.getStatus(); const status = voiceController.getStatus();
res.json(status); res.json(status);
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
}); });
// GET /api/guilds - List available guilds // GET /api/guilds - List available guilds
router.get("/guilds", (_req, res, next) => { router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
try { try {
const guilds = voiceController.listGuilds(); const guilds = voiceController.listGuilds();
res.json(guilds); res.json(guilds);
@@ -58,7 +71,7 @@ export function createVoiceRoutes(
}); });
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild // 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 { try {
const { guildId } = req.params; const { guildId } = req.params;
@@ -66,7 +79,7 @@ export function createVoiceRoutes(
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); 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); res.json(channels);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -74,7 +87,7 @@ export function createVoiceRoutes(
}); });
// GET /api/guilds/:guildId/channels - List text channels in a guild // 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 { try {
const { guildId } = req.params; const { guildId } = req.params;
@@ -82,7 +95,7 @@ export function createVoiceRoutes(
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); 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); res.json(channels);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -90,7 +103,7 @@ export function createVoiceRoutes(
}); });
// POST /api/connect - Connect to a voice channel // 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 { try {
const { guildId, channelId } = req.body as { const { guildId, channelId } = req.body as {
guildId?: string; guildId?: string;
@@ -125,7 +138,7 @@ export function createVoiceRoutes(
}); });
// POST /api/disconnect - Disconnect from voice channel // 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 { try {
logger.info("Disconnecting from voice channel"); logger.info("Disconnecting from voice channel");

View File

@@ -183,6 +183,7 @@ export async function startWebserver(
// Create broadcaster instance // Create broadcaster instance
const broadcaster = createBroadcaster(); const broadcaster = createBroadcaster();
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster; (globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
(globalThis as any).ADMIN_PASSWORD = config.ADMIN_PASSWORD;
const streamer = new Streamer(_client); const streamer = new Streamer(_client);
const screenController = createScreenShareController({ 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 // Register route modules
app.use( app.use(
"/api", "/api",
@@ -284,17 +276,22 @@ export async function startWebserver(
); );
app.use( app.use(
"/api", "/api",
adminAuth,
createVoiceRoutes({ createVoiceRoutes({
voiceController, voiceController,
patchSharedUIState, patchSharedUIState,
broadcaster, broadcaster,
adminPassword: config.ADMIN_PASSWORD,
}), }),
); );
app.use("/api", createMessageRoutes()); app.use("/api", createMessageRoutes());
app.use("/api", createAnalysisRoutes()); app.use("/api", createAnalysisRoutes());
app.use("/api", createSyncRoutes(_client)); 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 // Inbound: Discord PCM → tagged chunks → browser
(globalThis as VoiceGlobals).broadcastPcmToWeb = ( (globalThis as VoiceGlobals).broadcastPcmToWeb = (