From 976823a556cce0915096809a2fae67055b9e4b0a Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Tue, 10 Mar 2026 15:54:54 -0300 Subject: [PATCH] chore(backend): close lint debt and enforce strict route typing --- CHANGELOG.md | 10 +++ README.md | 6 +- packages/backend/package.json | 5 +- packages/backend/src/middleware/session.ts | 58 +++++++------ packages/backend/src/middleware/validate.ts | 17 ++-- packages/backend/src/routes/lastfm.ts | 15 +++- packages/backend/src/routes/management.ts | 56 +++++++----- .../src/routes/managementAutoMessages.ts | 36 ++++++-- .../backend/src/routes/managementEmbeds.ts | 35 ++++++-- packages/backend/src/routes/moderation.ts | 30 +++++-- .../src/routes/music/playbackRoutes.ts | 85 ++++++++++++++----- .../backend/src/routes/music/queueRoutes.ts | 64 ++++++++++---- .../backend/src/routes/music/stateRoutes.ts | 15 +++- packages/backend/src/routes/toggles.ts | 18 +++- packages/backend/src/routes/twitch.ts | 5 +- 15 files changed, 324 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a681bbc6c..bdd1f117e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deploy workflow auth smoke gate now strictly requires `/api/health/auth-config` with `status=ok`, no warnings, and healthy auth-session/Redis flags (no fallback to generic health endpoint) +- Backend route handlers now use schema-typed request parsing and explicit auth + user-id guards (removed unsafe `any` request/body/query reads and non-null + assertions across management, moderation, music, toggles, and twitch routes) +- Session middleware now uses typed `session-file-store` import wiring and + strict `connect-redis` adapter wiring without unsafe casts ### Added @@ -29,6 +34,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `status`, auth/runtime flags, and deploy-safe warnings for OAuth/session validation +### Changed + +- Backend lint no longer uses scoped ignore guardrails; strict lint now runs + across the full backend package by default (issue #136 closure) + ## [2.6.6] - 2026-03-10 ### Added diff --git a/README.md b/README.md index e0e9d5ebb..6c5828e31 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,9 @@ npm run test:coverage # With coverage report npm run format # Prettier ``` -Backend lint is currently scoped to active quality-gate paths while legacy strict -rule debt is tracked in [issue #136](https://github.com/LucasSantana-Dev/Lucky/issues/136) -and auditable via `npm run lint:full --workspace=packages/backend`. +Backend lint now runs in strict mode across all backend routes and middleware. +Use `npm run lint:full --workspace=packages/backend` for explicit backend-only +verification in CI or local checks. ### Remote Deploy (No SSH) diff --git a/packages/backend/package.json b/packages/backend/package.json index 2d0f3f239..1c2dfcb03 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -12,9 +12,8 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "lint:base": "eslint . -c ../../eslint.config.js --ignore-pattern src/middleware/session.ts --ignore-pattern src/middleware/validate.ts --ignore-pattern src/routes/lastfm.ts --ignore-pattern src/routes/management.ts --ignore-pattern src/routes/managementAutoMessages.ts --ignore-pattern src/routes/managementEmbeds.ts --ignore-pattern src/routes/moderation.ts --ignore-pattern src/routes/music/playbackRoutes.ts --ignore-pattern src/routes/music/queueRoutes.ts --ignore-pattern src/routes/music/stateRoutes.ts --ignore-pattern src/routes/toggles.ts --ignore-pattern src/routes/twitch.ts", - "lint": "npm run lint:base", - "lint:fix": "npm run lint:base -- --fix", + "lint": "eslint . -c ../../eslint.config.js", + "lint:fix": "npm run lint -- --fix", "lint:full": "eslint . -c ../../eslint.config.js" }, "dependencies": { diff --git a/packages/backend/src/middleware/session.ts b/packages/backend/src/middleware/session.ts index 60c3220a3..08beb2076 100644 --- a/packages/backend/src/middleware/session.ts +++ b/packages/backend/src/middleware/session.ts @@ -3,6 +3,7 @@ import { mkdirSync } from 'node:fs' import session from 'express-session' import { RedisStore } from 'connect-redis' import Redis from 'ioredis' +import sessionFileStoreFactory from 'session-file-store' import { debugLog, errorLog } from '@lucky/shared/utils' import type { Express } from 'express' @@ -33,7 +34,6 @@ type ConnectRedisClient = { scanIterator: (options: RedisScanOptions) => AsyncIterable } -type RedisStoreClient = ConstructorParameters[0]['client'] type SessionMethodName = 'get' | 'set' | 'destroy' | 'touch' type SessionCallback = (error?: unknown, data?: unknown) => void @@ -144,7 +144,12 @@ export class ResilientSessionStore extends session.Store { callback: SessionCallback, ): void { if (this.fallbackActive) { - this.invokeStoreMethod(this.fallbackStore, methodName, args, callback) + this.invokeStoreMethod( + this.fallbackStore, + methodName, + args, + callback, + ) return } @@ -171,7 +176,10 @@ export class ResilientSessionStore extends session.Store { get( sid: string, - callback: (error?: unknown, sessionData?: session.SessionData | null) => void, + callback: ( + error?: unknown, + sessionData?: session.SessionData | null, + ) => void, ): void { this.execute('get', [sid], callback as SessionCallback) } @@ -184,10 +192,7 @@ export class ResilientSessionStore extends session.Store { this.execute('set', [sid, sessionData], callback as SessionCallback) } - destroy( - sid: string, - callback: (error?: unknown) => void = () => {}, - ): void { + destroy(sid: string, callback: (error?: unknown) => void = () => {}): void { this.execute('destroy', [sid], callback as SessionCallback) } @@ -230,7 +235,7 @@ function createRedisStore(): session.Store | undefined { const storeClient = createConnectRedisClientAdapter(client) return new RedisStore({ - client: storeClient as unknown as RedisStoreClient, + client: storeClient, prefix: 'lucky:sess:', }) } catch (error) { @@ -246,9 +251,7 @@ function createRedisStore(): session.Store | undefined { function createFileStore(sessionPath: string): session.Store | undefined { try { mkdirSync(sessionPath, { recursive: true }) - // eslint-disable-next-line @typescript-eslint/no-require-imports - const FileStoreFactory = require('session-file-store') - const FileStore = FileStoreFactory(session) + const FileStore = sessionFileStoreFactory(session) return new FileStore({ path: sessionPath, ttl: 7 * 24 * 60 * 60, @@ -288,32 +291,35 @@ export function setupSessionMiddleware(app: Express): void { : fallbackStore const isMemoryFallback = fallbackStore.constructor.name === 'MemoryStore' - let storeType = 'file-based' - if (redisStore) { - storeType = isMemoryFallback - ? 'Redis with in-memory fallback' - : 'Redis with file fallback' - } else if (isMemoryFallback) { - storeType = 'in-memory' - } - - debugLog({ message: `Using ${storeType} session store` }) app.use( session({ - store, - secret: sessionSecret ?? 'default-secret-change-in-production', + secret: sessionSecret || 'fallback-secret-change-in-production', + name: 'sessionId', resave: false, saveUninitialized: false, - name: 'sessionId', - proxy: isProduction, cookie: { secure: isProduction, httpOnly: true, - maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, path: '/', }, + store, + rolling: true, + unset: 'destroy', }), ) + + debugLog({ + message: 'Session middleware configured', + data: { + sessionPath, + store: redisStore + ? `redis+fallback:${isMemoryFallback ? 'memory' : 'file'}` + : isMemoryFallback + ? 'memory' + : 'file', + }, + }) } diff --git a/packages/backend/src/middleware/validate.ts b/packages/backend/src/middleware/validate.ts index 020fa2d46..ca79bdfad 100644 --- a/packages/backend/src/middleware/validate.ts +++ b/packages/backend/src/middleware/validate.ts @@ -1,9 +1,11 @@ import type { Request, Response, NextFunction } from 'express' import { z } from 'zod' -export function validateBody(schema: T) { +type Schema = z.ZodType + +export function validateBody(schema: Schema) { return (req: Request, res: Response, next: NextFunction) => { - const result = schema.safeParse(req.body) + const result = schema.safeParse(req.body as unknown) if (!result.success) { const errors = result.error.errors.map((e) => ({ field: e.path.join('.'), @@ -11,14 +13,15 @@ export function validateBody(schema: T) { })) return res.status(400).json({ error: 'Validation failed', errors }) } + req.body = result.data next() } } -export function validateQuery(schema: T) { +export function validateQuery(schema: Schema) { return (req: Request, res: Response, next: NextFunction) => { - const result = schema.safeParse(req.query) + const result = schema.safeParse(req.query as unknown) if (!result.success) { const errors = result.error.errors.map((e) => ({ field: e.path.join('.'), @@ -26,13 +29,14 @@ export function validateQuery(schema: T) { })) return res.status(400).json({ error: 'Validation failed', errors }) } + next() } } -export function validateParams(schema: T) { +export function validateParams(schema: Schema) { return (req: Request, res: Response, next: NextFunction) => { - const result = schema.safeParse(req.params) + const result = schema.safeParse(req.params as unknown) if (!result.success) { const errors = result.error.errors.map((e) => ({ field: e.path.join('.'), @@ -40,6 +44,7 @@ export function validateParams(schema: T) { })) return res.status(400).json({ error: 'Validation failed', errors }) } + next() } } diff --git a/packages/backend/src/routes/lastfm.ts b/packages/backend/src/routes/lastfm.ts index 1dea6adaa..fbba606fa 100644 --- a/packages/backend/src/routes/lastfm.ts +++ b/packages/backend/src/routes/lastfm.ts @@ -1,5 +1,6 @@ import type { Express, Request, Response } from 'express' import crypto from 'node:crypto' +import { z } from 'zod' import { errorLog, debugLog } from '@lucky/shared/utils' import { lastFmLinkService } from '@lucky/shared/services' import { @@ -15,6 +16,7 @@ import { getPrimaryFrontendUrl } from '../utils/frontendOrigin' const LASTFM_STATE_COOKIE = 'lastfm_state' const STATE_MAX_AGE_SEC = 600 +const lastFmCallbackQuery = z.object({ token: z.string().min(1) }) function getLinkSecret(): string { const secret = @@ -174,14 +176,17 @@ export function setupLastFmRoutes(app: Express): void { app.get('/api/lastfm/callback', async (req: Request, res: Response) => { const frontendUrl = getFrontendUrl() try { - const token = req.query.token - const stateFromCookie = req.cookies?.[LASTFM_STATE_COOKIE] + const cookies = req.cookies as Record | undefined + const stateFromCookie = cookies?.[LASTFM_STATE_COOKIE] + const parsedQuery = lastFmCallbackQuery.safeParse(req.query) res.clearCookie(LASTFM_STATE_COOKIE, { path: '/' }) - if (!token || typeof token !== 'string') { + + if (!parsedQuery.success) { return res.redirect( `${frontendUrl}/?error=lastfm_missing_token`, ) } + if (!stateFromCookie || typeof stateFromCookie !== 'string') { return res.redirect( `${frontendUrl}/?error=lastfm_missing_state`, @@ -194,7 +199,9 @@ export function setupLastFmRoutes(app: Express): void { `${frontendUrl}/?error=lastfm_invalid_state`, ) } - const session = await exchangeTokenForSession(token) + const session = await exchangeTokenForSession( + parsedQuery.data.token, + ) if (!session) { return res.redirect( `${frontendUrl}/?error=lastfm_exchange_failed`, diff --git a/packages/backend/src/routes/management.ts b/packages/backend/src/routes/management.ts index 56e098285..df9b5e509 100644 --- a/packages/backend/src/routes/management.ts +++ b/packages/backend/src/routes/management.ts @@ -7,6 +7,7 @@ import { } from '../middleware/validate' import { writeLimiter } from '../middleware/rateLimit' import { asyncHandler } from '../middleware/asyncHandler' +import { AppError } from '../errors/AppError' import { managementSchemas as s } from '../schemas/management' import { autoModService, @@ -21,6 +22,14 @@ function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] } +function requireUserId(req: AuthenticatedRequest): string { + if (!req.userId) { + throw AppError.unauthorized() + } + + return req.userId +} + export function setupManagementRoutes(app: Express): void { app.get( '/api/guilds/:guildId/automod/settings', @@ -42,18 +51,18 @@ export function setupManagementRoutes(app: Express): void { validateBody(s.autoModSettingsBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) - const settings = await autoModService.updateSettings( - guildId, - req.body, - ) + const userId = requireUserId(req) + const body = s.autoModSettingsBody.parse(req.body) + const settings = await autoModService.updateSettings(guildId, body) + await serverLogService.logAutoModSettingsChange( guildId, { module: 'general', enabled: true, - changes: req.body, + changes: body, }, - req.userId!, + userId, ) res.json(settings) }), @@ -79,18 +88,20 @@ export function setupManagementRoutes(app: Express): void { validateBody(s.createCommandBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) - const { name, response, description } = req.body + const userId = requireUserId(req) + const body = s.createCommandBody.parse(req.body) + const { name, response, description } = body const command = await customCommandService.createCommand( guildId, name, response, - { description, createdBy: req.userId }, + { description, createdBy: userId }, ) await serverLogService.logCustomCommandChange( guildId, 'created', { commandName: name }, - req.userId!, + userId, ) res.status(201).json(command) }), @@ -104,17 +115,19 @@ export function setupManagementRoutes(app: Express): void { validateBody(s.updateCommandBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const name = p(req.params.name) + const body = s.updateCommandBody.parse(req.body) const command = await customCommandService.updateCommand( guildId, name, - req.body, + body, ) await serverLogService.logCustomCommandChange( guildId, 'updated', - { commandName: name, changes: req.body }, - req.userId!, + { commandName: name, changes: body }, + userId, ) res.json(command) }), @@ -127,13 +140,14 @@ export function setupManagementRoutes(app: Express): void { validateParams(s.commandNameParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const name = p(req.params.name) await customCommandService.deleteCommand(guildId, name) await serverLogService.logCustomCommandChange( guildId, 'deleted', { commandName: name }, - req.userId!, + userId, ) res.json({ success: true }) }), @@ -149,8 +163,10 @@ export function setupManagementRoutes(app: Express): void { validateQuery(s.logsQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) - const limit = parseInt((req.query.limit as string) || '50') - const type = req.query.type ? (req.query.type as string) : undefined + const query = s.logsQuery.parse(req.query) + const limit = query.limit ?? 50 + const type = query.type + if (type) { const logs = await serverLogService.getLogsByType( guildId, @@ -160,6 +176,7 @@ export function setupManagementRoutes(app: Express): void { res.json({ logs }) return } + const logs = await serverLogService.getRecentLogs(guildId, limit) res.json({ logs }) }), @@ -172,13 +189,10 @@ export function setupManagementRoutes(app: Express): void { validateQuery(s.logsSearchQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const query = s.logsSearchQuery.parse(req.query) const logs = await serverLogService.searchLogs(guildId, { - type: req.query.type - ? (req.query.type as string as LogType) - : undefined, - userId: req.query.userId - ? (req.query.userId as string) - : undefined, + type: query.type as LogType | undefined, + userId: query.userId, }) res.json({ logs }) }), diff --git a/packages/backend/src/routes/managementAutoMessages.ts b/packages/backend/src/routes/managementAutoMessages.ts index 2b798c210..128a5cb72 100644 --- a/packages/backend/src/routes/managementAutoMessages.ts +++ b/packages/backend/src/routes/managementAutoMessages.ts @@ -7,6 +7,7 @@ import { } from '../middleware/validate' import { writeLimiter } from '../middleware/rateLimit' import { asyncHandler } from '../middleware/asyncHandler' +import { AppError } from '../errors/AppError' import { autoMessageSchemas as s } from '../schemas/autoMessages' import { autoMessageService, serverLogService } from '@lucky/shared/services' @@ -14,6 +15,14 @@ function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] } +function requireUserId(req: AuthenticatedRequest): string { + if (!req.userId) { + throw AppError.unauthorized() + } + + return req.userId +} + export function setupAutoMessageRoutes(app: Express): void { app.get( '/api/guilds/:guildId/automessages', @@ -22,7 +31,9 @@ export function setupAutoMessageRoutes(app: Express): void { validateQuery(s.messagesQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) - const type = req.query.type ? (req.query.type as string) : undefined + const query = s.messagesQuery.parse(req.query) + const type = query.type + if (type) { const messages = await autoMessageService.getMessagesByType( guildId, @@ -31,6 +42,7 @@ export function setupAutoMessageRoutes(app: Express): void { res.json({ messages }) return } + const [welcome, leave] = await Promise.all([ autoMessageService.getWelcomeMessage(guildId), autoMessageService.getLeaveMessage(guildId), @@ -47,6 +59,8 @@ export function setupAutoMessageRoutes(app: Express): void { validateBody(s.createMessageBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) + const body = s.createMessageBody.parse(req.body) const { type, message, @@ -54,7 +68,7 @@ export function setupAutoMessageRoutes(app: Express): void { trigger, exactMatch, cronSchedule, - } = req.body + } = body const autoMsg = await autoMessageService.createMessage( guildId, type, @@ -65,7 +79,7 @@ export function setupAutoMessageRoutes(app: Express): void { guildId, 'created', { type, channelId }, - req.userId!, + userId, ) res.status(201).json(autoMsg) }), @@ -79,13 +93,15 @@ export function setupAutoMessageRoutes(app: Express): void { validateBody(s.updateMessageBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const id = p(req.params.id) - const updated = await autoMessageService.updateMessage(id, req.body) + const body = s.updateMessageBody.parse(req.body) + const updated = await autoMessageService.updateMessage(id, body) await serverLogService.logAutoMessageChange( guildId, 'updated', - { type: updated.type, changes: req.body }, - req.userId!, + { type: updated.type, changes: body }, + userId, ) res.json(updated) }), @@ -99,14 +115,15 @@ export function setupAutoMessageRoutes(app: Express): void { validateBody(s.toggleBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const id = p(req.params.id) - const { enabled } = req.body + const { enabled } = s.toggleBody.parse(req.body) const updated = await autoMessageService.toggleMessage(id, enabled) await serverLogService.logAutoMessageChange( guildId, enabled ? 'enabled' : 'disabled', { type: updated.type }, - req.userId!, + userId, ) res.json(updated) }), @@ -119,13 +136,14 @@ export function setupAutoMessageRoutes(app: Express): void { validateParams(s.messageIdParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const id = p(req.params.id) await autoMessageService.deleteMessage(id) await serverLogService.logAutoMessageChange( guildId, 'disabled', { type: 'deleted' }, - req.userId!, + userId, ) res.json({ success: true }) }), diff --git a/packages/backend/src/routes/managementEmbeds.ts b/packages/backend/src/routes/managementEmbeds.ts index dab168a8d..2b4115c4f 100644 --- a/packages/backend/src/routes/managementEmbeds.ts +++ b/packages/backend/src/routes/managementEmbeds.ts @@ -5,12 +5,24 @@ import { writeLimiter } from '../middleware/rateLimit' import { asyncHandler } from '../middleware/asyncHandler' import { AppError } from '../errors/AppError' import { embedSchemas as s } from '../schemas/embeds' -import { embedBuilderService, serverLogService } from '@lucky/shared/services' +import { + embedBuilderService, + serverLogService, + type EmbedData, +} from '@lucky/shared/services' function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] } +function requireUserId(req: AuthenticatedRequest): string { + if (!req.userId) { + throw AppError.unauthorized() + } + + return req.userId +} + export function setupEmbedRoutes(app: Express): void { app.get( '/api/guilds/:guildId/embeds', @@ -32,7 +44,10 @@ export function setupEmbedRoutes(app: Express): void { validateBody(s.createEmbedBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) - const { name, embedData, description } = req.body + const userId = requireUserId(req) + const body = s.createEmbedBody.parse(req.body) + const { name, description } = body + const embedData = body.embedData as unknown as Partial const validation = embedBuilderService.validateEmbedData(embedData) if (!validation.valid) { throw AppError.badRequest( @@ -45,13 +60,13 @@ export function setupEmbedRoutes(app: Express): void { name, embedData, description, - req.userId, + userId, ) await serverLogService.logEmbedTemplateChange( guildId, 'created', { templateName: name }, - req.userId!, + userId, ) res.status(201).json(template) }), @@ -65,17 +80,22 @@ export function setupEmbedRoutes(app: Express): void { validateBody(s.updateEmbedBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const name = p(req.params.name) + const body = s.updateEmbedBody.parse(req.body) + const updates = body as unknown as Partial< + EmbedData & { description: string } + > const template = await embedBuilderService.updateTemplate( guildId, name, - req.body, + updates, ) await serverLogService.logEmbedTemplateChange( guildId, 'updated', { templateName: name }, - req.userId!, + userId, ) res.json(template) }), @@ -88,13 +108,14 @@ export function setupEmbedRoutes(app: Express): void { validateParams(s.embedNameParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const name = p(req.params.name) await embedBuilderService.deleteTemplate(guildId, name) await serverLogService.logEmbedTemplateChange( guildId, 'deleted', { templateName: name }, - req.userId!, + userId, ) res.json({ success: true }) }), diff --git a/packages/backend/src/routes/moderation.ts b/packages/backend/src/routes/moderation.ts index a575f62a6..41340cb88 100644 --- a/packages/backend/src/routes/moderation.ts +++ b/packages/backend/src/routes/moderation.ts @@ -15,6 +15,14 @@ function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] } +function requireUserId(req: AuthenticatedRequest): string { + if (!req.userId) { + throw AppError.unauthorized() + } + + return req.userId +} + export function setupModerationRoutes(app: Express): void { app.get( '/api/guilds/:guildId/moderation/cases', @@ -22,7 +30,8 @@ export function setupModerationRoutes(app: Express): void { validateParams(s.guildIdParam), validateQuery(s.casesQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { - const limit = parseInt(req.query.limit as string) || 25 + const query = s.casesQuery.parse(req.query) + const limit = query.limit ?? 25 const cases = await moderationService.getRecentCases( p(req.params.guildId), limit, @@ -53,7 +62,8 @@ export function setupModerationRoutes(app: Express): void { validateParams(s.userCasesParam), validateQuery(s.userCasesQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { - const activeOnly = req.query.activeOnly === 'true' + const query = s.userCasesQuery.parse(req.query) + const activeOnly = query.activeOnly === 'true' const cases = await moderationService.getUserCases( p(req.params.guildId), p(req.params.userId), @@ -71,8 +81,9 @@ export function setupModerationRoutes(app: Express): void { validateBody(s.updateReasonBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const caseNumber = Number(req.params.caseNumber) - const { reason } = req.body + const { reason } = s.updateReasonBody.parse(req.body) const modCase = await moderationService.getCase(guildId, caseNumber) if (!modCase) { @@ -87,7 +98,7 @@ export function setupModerationRoutes(app: Express): void { oldValue: modCase.reason ?? undefined, newValue: reason, }, - req.userId!, + userId, ) res.json({ success: true }) }), @@ -100,6 +111,7 @@ export function setupModerationRoutes(app: Express): void { validateParams(s.caseIdParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) const caseId = p(req.params.caseId) const updated = await moderationService.deactivateCase(caseId) await serverLogService.logCaseUpdate( @@ -108,7 +120,7 @@ export function setupModerationRoutes(app: Express): void { caseNumber: updated.caseNumber, changeType: 'deactivated', }, - req.userId!, + userId, ) res.json(updated) }), @@ -134,14 +146,16 @@ export function setupModerationRoutes(app: Express): void { validateBody(s.updateSettingsBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) + const userId = requireUserId(req) + const body = s.updateSettingsBody.parse(req.body) const settings = await moderationService.updateSettings( guildId, - req.body, + body, ) await serverLogService.logSettingsChange( guildId, - { setting: 'moderation', newValue: req.body }, - req.userId!, + { setting: 'moderation', newValue: body }, + userId, ) res.json(settings) }), diff --git a/packages/backend/src/routes/music/playbackRoutes.ts b/packages/backend/src/routes/music/playbackRoutes.ts index 3efd20394..6d5dd2187 100644 --- a/packages/backend/src/routes/music/playbackRoutes.ts +++ b/packages/backend/src/routes/music/playbackRoutes.ts @@ -1,20 +1,51 @@ import type { Express, Response } from 'express' +import { z } from 'zod' import { requireAuth, type AuthenticatedRequest } from '../../middleware/auth' import { asyncHandler } from '../../middleware/asyncHandler' import { AppError } from '../../errors/AppError' import { musicControlService } from '@lucky/shared/services' import { param, buildCommand } from './helpers' +const playBodySchema = z.object({ + query: z.string().min(1), + voiceChannelId: z.string().min(1).optional(), +}) + +const volumeBodySchema = z.object({ + volume: z.number().min(0).max(100), +}) + +const repeatBodySchema = z.object({ + mode: z.enum(['off', 'track', 'queue', 'autoplay']), +}) + +const seekBodySchema = z.object({ + position: z.number().min(0), +}) + +function requireUserId(req: AuthenticatedRequest): string { + if (!req.userId) { + throw AppError.unauthorized() + } + + return req.userId +} + export function setupPlaybackRoutes(app: Express): void { app.post( '/api/guilds/:guildId/music/play', requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) - const { query, voiceChannelId } = req.body - if (!query) throw AppError.badRequest('Query is required') + const userId = requireUserId(req) + const body = playBodySchema.safeParse(req.body) + + if (!body.success) { + throw AppError.badRequest('Query is required') + } - const cmd = buildCommand(guildId, req.userId!, 'play', { + const { query, voiceChannelId } = body.data + const cmd = buildCommand(guildId, userId, 'play', { query, voiceChannelId, }) @@ -27,9 +58,10 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) + const userId = requireUserId(req) res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'pause'), + buildCommand(guildId, userId, 'pause'), ), ) }), @@ -40,9 +72,10 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) + const userId = requireUserId(req) res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'resume'), + buildCommand(guildId, userId, 'resume'), ), ) }), @@ -53,9 +86,10 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) + const userId = requireUserId(req) res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'skip'), + buildCommand(guildId, userId, 'skip'), ), ) }), @@ -66,9 +100,10 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) + const userId = requireUserId(req) res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'stop'), + buildCommand(guildId, userId, 'stop'), ), ) }), @@ -79,14 +114,17 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) - const { volume } = req.body - if (typeof volume !== 'number' || volume < 0 || volume > 100) { + const userId = requireUserId(req) + const body = volumeBodySchema.safeParse(req.body) + + if (!body.success) { throw AppError.badRequest('Volume must be 0-100') } + res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'volume', { - volume, + buildCommand(guildId, userId, 'volume', { + volume: body.data.volume, }), ), ) @@ -98,9 +136,10 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) + const userId = requireUserId(req) res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'shuffle'), + buildCommand(guildId, userId, 'shuffle'), ), ) }), @@ -111,15 +150,20 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) - const { mode } = req.body - if (!['off', 'track', 'queue', 'autoplay'].includes(mode)) { + const userId = requireUserId(req) + const body = repeatBodySchema.safeParse(req.body) + + if (!body.success) { throw AppError.badRequest( 'Mode must be off, track, queue, or autoplay', ) } + res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'repeat', { mode }), + buildCommand(guildId, userId, 'repeat', { + mode: body.data.mode, + }), ), ) }), @@ -130,16 +174,19 @@ export function setupPlaybackRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) - const { position } = req.body - if (typeof position !== 'number' || position < 0) { + const userId = requireUserId(req) + const body = seekBodySchema.safeParse(req.body) + + if (!body.success) { throw AppError.badRequest( 'Position must be a positive number (ms)', ) } + res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'seek', { - position, + buildCommand(guildId, userId, 'seek', { + position: body.data.position, }), ), ) diff --git a/packages/backend/src/routes/music/queueRoutes.ts b/packages/backend/src/routes/music/queueRoutes.ts index 149fc373e..d5a2d0d90 100644 --- a/packages/backend/src/routes/music/queueRoutes.ts +++ b/packages/backend/src/routes/music/queueRoutes.ts @@ -1,10 +1,33 @@ import type { Express, Response } from 'express' +import { z } from 'zod' import { requireAuth, type AuthenticatedRequest } from '../../middleware/auth' import { asyncHandler } from '../../middleware/asyncHandler' import { AppError } from '../../errors/AppError' import { musicControlService } from '@lucky/shared/services' import { param, buildCommand } from './helpers' +const moveQueueBodySchema = z.object({ + from: z.number(), + to: z.number(), +}) + +const removeQueueBodySchema = z.object({ + index: z.number(), +}) + +const importBodySchema = z.object({ + url: z.string().min(1), + voiceChannelId: z.string().min(1).optional(), +}) + +function requireUserId(req: AuthenticatedRequest): string { + if (!req.userId) { + throw AppError.unauthorized() + } + + return req.userId +} + export function setupQueueRoutes(app: Express): void { app.get( '/api/guilds/:guildId/music/queue', @@ -25,13 +48,16 @@ export function setupQueueRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) - const { from, to } = req.body - if (typeof from !== 'number' || typeof to !== 'number') { + const userId = requireUserId(req) + const body = moveQueueBodySchema.safeParse(req.body) + + if (!body.success) { throw AppError.badRequest('From and to positions are required') } - const cmd = buildCommand(guildId, req.userId!, 'queue_move', { - from, - to, + + const cmd = buildCommand(guildId, userId, 'queue_move', { + from: body.data.from, + to: body.data.to, }) res.json(await musicControlService.sendCommand(cmd)) }), @@ -42,12 +68,15 @@ export function setupQueueRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) - const { index } = req.body - if (typeof index !== 'number') { + const userId = requireUserId(req) + const body = removeQueueBodySchema.safeParse(req.body) + + if (!body.success) { throw AppError.badRequest('Track index is required') } - const cmd = buildCommand(guildId, req.userId!, 'queue_remove', { - index, + + const cmd = buildCommand(guildId, userId, 'queue_remove', { + index: body.data.index, }) res.json(await musicControlService.sendCommand(cmd)) }), @@ -58,9 +87,10 @@ export function setupQueueRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) + const userId = requireUserId(req) res.json( await musicControlService.sendCommand( - buildCommand(guildId, req.userId!, 'queue_clear'), + buildCommand(guildId, userId, 'queue_clear'), ), ) }), @@ -71,12 +101,16 @@ export function setupQueueRoutes(app: Express): void { requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = param(req.params.guildId) - const { url, voiceChannelId } = req.body - if (!url) throw AppError.badRequest('Playlist URL is required') + const userId = requireUserId(req) + const body = importBodySchema.safeParse(req.body) + + if (!body.success) { + throw AppError.badRequest('Playlist URL is required') + } - const cmd = buildCommand(guildId, req.userId!, 'import_playlist', { - url, - voiceChannelId, + const cmd = buildCommand(guildId, userId, 'import_playlist', { + url: body.data.url, + voiceChannelId: body.data.voiceChannelId, }) res.json(await musicControlService.sendCommand(cmd, 30000)) }), diff --git a/packages/backend/src/routes/music/stateRoutes.ts b/packages/backend/src/routes/music/stateRoutes.ts index 456cce820..8a691e257 100644 --- a/packages/backend/src/routes/music/stateRoutes.ts +++ b/packages/backend/src/routes/music/stateRoutes.ts @@ -23,8 +23,12 @@ export function setupStateRoutes(app: Express): void { res.write(`data: ${JSON.stringify(currentState)}\n\n`) } - if (!sseClients.has(guildId)) sseClients.set(guildId, new Set()) - sseClients.get(guildId)!.add(res) + let clients = sseClients.get(guildId) + if (!clients) { + clients = new Set() + sseClients.set(guildId, clients) + } + clients.add(res) const heartbeat = setInterval( () => res.write(': heartbeat\n\n'), @@ -33,9 +37,12 @@ export function setupStateRoutes(app: Express): void { req.on('close', () => { clearInterval(heartbeat) - sseClients.get(guildId)?.delete(res) - if (sseClients.get(guildId)?.size === 0) + const guildClients = sseClients.get(guildId) + guildClients?.delete(res) + + if (guildClients && guildClients.size === 0) { sseClients.delete(guildId) + } }) }, ) diff --git a/packages/backend/src/routes/toggles.ts b/packages/backend/src/routes/toggles.ts index 0a51729cf..550aa3880 100644 --- a/packages/backend/src/routes/toggles.ts +++ b/packages/backend/src/routes/toggles.ts @@ -17,12 +17,21 @@ function requireDeveloper(userId?: string): void { } } +function requireUserId(req: AuthenticatedRequest): string { + if (!req.userId) { + throw AppError.unauthorized() + } + + return req.userId +} + export function setupToggleRoutes(app: Express): void { app.get( '/api/toggles/global', requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { - requireDeveloper(req.userId) + const userId = requireUserId(req) + requireDeveloper(userId) const toggles = featureToggleService.getAllToggles() const result: Record = {} @@ -30,7 +39,7 @@ export function setupToggleRoutes(app: Express): void { for (const [name] of toggles) { result[name] = await featureToggleService.isEnabledGlobal( name, - req.userId!, + userId, ) } @@ -42,7 +51,8 @@ export function setupToggleRoutes(app: Express): void { '/api/toggles/global/:name', requireAuth, asyncHandler(async (req: AuthenticatedRequest, res: Response) => { - requireDeveloper(req.userId) + const userId = requireUserId(req) + requireDeveloper(userId) const toggleName = typeof req.params.name === 'string' @@ -60,7 +70,7 @@ export function setupToggleRoutes(app: Express): void { const enabled = await featureToggleService.isEnabledGlobal( toggleName as FeatureToggleName, - req.userId!, + userId, ) res.json({ name: toggleName, enabled }) diff --git a/packages/backend/src/routes/twitch.ts b/packages/backend/src/routes/twitch.ts index 88a86e5ee..c64416fd1 100644 --- a/packages/backend/src/routes/twitch.ts +++ b/packages/backend/src/routes/twitch.ts @@ -90,7 +90,8 @@ export function setupTwitchRoutes(app: Express): void { validateBody(addTwitchBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) - const { twitchUserId, twitchLogin, discordChannelId } = req.body + const body = addTwitchBody.parse(req.body) + const { twitchUserId, twitchLogin, discordChannelId } = body const success = await twitchNotificationService.add( guildId, discordChannelId, @@ -109,7 +110,7 @@ export function setupTwitchRoutes(app: Express): void { validateBody(removeTwitchBody), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) - const { twitchUserId } = req.body + const { twitchUserId } = removeTwitchBody.parse(req.body) const success = await twitchNotificationService.remove( guildId, twitchUserId,