import { Router, type NextFunction, type Request, type Response } from 'express' import bcrypt from 'bcryptjs' import * as z from 'zod/v4' import { mkdir, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import sharp from 'sharp' import type { TypedServer } from '../middleware/types.js' import { roomRepo } from '../repositories/roomRepository.js' import { userRepo } from '../repositories/userRepository.js' import { audioProxyPolicyRepo } from '../repositories/audioProxyPolicyRepository.js' import { globalBackgroundRepo } from '../repositories/globalBackgroundRepository.js' import { databasePath } from '../repositories/database.js' import { destroyRoom } from '../services/roomLifecycleService.js' import { logger } from '../utils/logger.js' import { EVENTS } from '@music-together/shared' function auditContext(req: Request): Record { return { operatorId: req.identityUserId ?? null, requestIp: req.ip, method: req.method, path: req.originalUrl, } } function requireServerAdmin(req: Request, res: Response, next: NextFunction): void { if (!req.identityUserId || !userRepo.isServerAdmin(req.identityUserId)) { logger.warn('非服务器管理员尝试访问管理接口', { event: 'admin.access_denied', ...auditContext(req), }) res.status(403).json({ error: 'Forbidden' }) return } next() } const resetPasswordSchema = z.object({ password: z.string().min(8, '密码至少需要 8 个字符').max(128), }) const audioProxyPolicyPatchSchema = z.object({ kugouForceProxy: z.boolean(), }) const backgroundSourceSchema = z .object({ image: z.string().min(1).optional(), imageUrl: z.string().url().optional(), }) .refine((value) => Boolean(value.image) !== Boolean(value.imageUrl), { message: 'Provide either an image upload or an image URL', }) const backgroundPatchSchema = z .object({ glassOverlay: z.boolean().optional(), colorPreset: z.enum(['gold', 'ocean', 'rose', 'violet', 'sunset', 'mint', 'mono']).optional(), backgroundBrightness: z.number().int().min(20).max(100).optional(), autoTint: z.boolean().optional(), }) .refine( (value) => value.glassOverlay !== undefined || value.colorPreset !== undefined || value.backgroundBrightness !== undefined || value.autoTint !== undefined, { message: 'No background setting was provided', }, ) const MAX_BACKGROUND_BYTES = 6 * 1024 * 1024 export function createAdminRoutes(io: TypedServer): Router { const router = Router() router.use(requireServerAdmin) router.get('/audio-proxy-policy', (req, res) => { const policy = audioProxyPolicyRepo.get() logger.info('服务器管理员查看了音频代理策略', { event: 'admin.audio_proxy_policy_viewed', ...auditContext(req), }) res.json(policy) }) router.patch('/audio-proxy-policy', (req, res) => { const parsed = audioProxyPolicyPatchSchema.safeParse(req.body) if (!parsed.success) { res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid audio proxy policy' }) return } const policy = audioProxyPolicyRepo.update(parsed.data) io.to('lobby').emit(EVENTS.SERVER_AUDIO_PROXY_POLICY, policy) logger.info('服务器管理员更新了音频代理策略', { event: 'admin.audio_proxy_policy_updated', ...auditContext(req), ...policy, }) res.json(policy) }) router.get('/background', (req, res) => { const settings = globalBackgroundRepo.get() logger.info('服务器管理员查看了全局背景设置', { event: 'admin.global_background_viewed', ...auditContext(req), hasBackground: Boolean(settings.backgroundUrl), }) res.json(settings) }) router.post('/background', async (req, res) => { const parsed = backgroundSourceSchema.safeParse(req.body) if (!parsed.success) { res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid image' }) return } try { if (parsed.data.imageUrl) { const parsedUrl = new URL(parsed.data.imageUrl) if (!['http:', 'https:'].includes(parsedUrl.protocol)) { res.status(400).json({ error: 'Only HTTP and HTTPS image URLs are supported' }) return } const settings = globalBackgroundRepo.update({ backgroundUrl: parsed.data.imageUrl }) io.emit(EVENTS.SERVER_GLOBAL_BACKGROUND, settings) res.json(settings) return } const match = /^data:image\/(png|jpeg|jpg|webp);base64,([a-z0-9+/=]+)$/i.exec(parsed.data.image!) if (!match) throw new Error('Only PNG, JPEG, and WebP images are supported') const input = Buffer.from(match[2]!, 'base64') if (input.length > MAX_BACKGROUND_BYTES) throw new Error('Background image must be 6MB or smaller') const output = await sharp(input, { failOn: 'error' }) .rotate() .resize(2560, 1440, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 84 }) .toBuffer() const backgroundsDir = path.join(path.dirname(databasePath), 'backgrounds') await mkdir(backgroundsDir, { recursive: true }) await writeFile(path.join(backgroundsDir, 'global-background.webp'), output) const settings = globalBackgroundRepo.update({ backgroundUrl: `/uploads/backgrounds/global-background.webp?v=${Date.now()}`, }) io.emit(EVENTS.SERVER_GLOBAL_BACKGROUND, settings) logger.info('服务器管理员更新了全局背景', { event: 'admin.global_background_updated', ...auditContext(req), inputBytes: input.length, outputBytes: output.length, }) res.json(settings) } catch (err) { logger.warn('Global background processing failed', { err, ...auditContext(req) }) res.status(400).json({ error: 'Invalid image data' }) } }) router.patch('/background', (req, res) => { const parsed = backgroundPatchSchema.safeParse(req.body) if (!parsed.success) { res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid background settings' }) return } const settings = globalBackgroundRepo.update(parsed.data) io.emit(EVENTS.SERVER_GLOBAL_BACKGROUND, settings) res.json(settings) }) router.delete('/background', async (req, res) => { try { const backgroundsDir = path.join(path.dirname(databasePath), 'backgrounds') await rm(path.join(backgroundsDir, 'global-background.webp'), { force: true }) const settings = globalBackgroundRepo.update({ backgroundUrl: null }) io.emit(EVENTS.SERVER_GLOBAL_BACKGROUND, settings) logger.info('服务器管理员移除了全局背景', { event: 'admin.global_background_removed', ...auditContext(req), }) res.json(settings) } catch (err) { logger.error('Global background removal failed', err, auditContext(req)) res.status(500).json({ error: 'Failed to remove background' }) } }) router.get('/users', (req, res) => { const users = userRepo.list() logger.info('服务器管理员查看了账号列表', { event: 'admin.users_viewed', ...auditContext(req), resultCount: users.length, }) res.json({ users: users.map((user) => ({ id: user.id, nickname: user.nickname, avatarUrl: user.avatarUrl, role: user.role, hasPassword: Boolean(user.passwordHash), createdAt: user.createdAt, updatedAt: user.updatedAt, lastSeenAt: user.lastSeenAt, })), }) }) router.delete('/users/:userId', (req, res) => { if (req.params.userId === req.identityUserId) { logger.warn('服务器管理员删除账号操作被拒绝', { event: 'admin.user_delete_rejected', ...auditContext(req), targetUserId: req.params.userId, reason: 'self_delete', }) res.status(400).json({ error: '不能删除当前登录的管理员账号' }) return } const target = userRepo.get(req.params.userId) if (!target) { logger.warn('服务器管理员删除账号操作失败', { event: 'admin.user_delete_failed', ...auditContext(req), targetUserId: req.params.userId, reason: 'not_found', }) res.status(404).json({ error: 'User not found' }) return } userRepo.delete(target.id) logger.info(`服务器管理员删除了账号 ${target.id}`, { event: 'admin.user_deleted', ...auditContext(req), targetUserId: target.id, targetNickname: target.nickname, targetRole: target.role, targetHadPassword: Boolean(target.passwordHash), }) res.status(204).send() }) router.post('/users/:userId/reset-password', async (req, res) => { const parsed = resetPasswordSchema.safeParse(req.body) if (!parsed.success) { logger.warn('服务器管理员重置账号密码操作被拒绝', { event: 'admin.user_password_reset_rejected', ...auditContext(req), targetUserId: req.params.userId, reason: 'invalid_password', }) res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid password' }) return } const user = userRepo.get(req.params.userId) if (!user) { logger.warn('服务器管理员重置账号密码操作失败', { event: 'admin.user_password_reset_failed', ...auditContext(req), targetUserId: req.params.userId, reason: 'not_found', }) res.status(404).json({ error: 'User not found' }) return } userRepo.setPasswordHash(user.id, await bcrypt.hash(parsed.data.password, 12)) logger.info(`服务器管理员重置了账号 ${user.id} 的密码`, { event: 'admin.user_password_reset', ...auditContext(req), targetUserId: user.id, targetNickname: user.nickname, targetRole: user.role, }) res.status(204).send() }) router.get('/rooms', (req, res) => { // Read the complete repository directly; the lobby projection intentionally filters hidden rooms. const rooms = Array.from(roomRepo.getAll().values()) logger.info('服务器管理员查看了活跃房间列表', { event: 'admin.rooms_viewed', ...auditContext(req), resultCount: rooms.length, }) res.json({ rooms: rooms.map((room) => ({ id: room.id, name: room.name, creatorId: room.creatorId, userCount: room.users.length, hasPassword: room.password !== null, hidden: room.hidden, permanent: room.permanent, currentTrackTitle: room.currentTrack?.title ?? null, })), }) }) router.post('/rooms/:roomId/dissolve', (req, res) => { const room = roomRepo.get(req.params.roomId) if (!room) { logger.warn('服务器管理员解散房间操作失败', { event: 'admin.room_dissolve_failed', ...auditContext(req), targetRoomId: req.params.roomId, reason: 'not_found', }) res.status(404).json({ error: 'Room not found' }) return } const roomAudit = { targetRoomId: room.id, targetRoomName: room.name, creatorId: room.creatorId, userCount: room.users.length, passwordProtected: room.password !== null, } destroyRoom(room.id, io) logger.info(`服务器管理员解散了房间 ${room.id}`, { event: 'admin.room_dissolved', ...auditContext(req), ...roomAudit, }) res.status(204).send() }) return router }