ghp / packages /server /src /routes /admin.ts
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
11.6 kB
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<string, unknown> {
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
}