const fs = require('fs/promises') const path = require('path') const express = require('express') const multer = require('multer') const bcrypt = require('bcryptjs') const { z } = require('zod') const { nanoid } = require('nanoid') const { findUserById, findGameById, createGame, updateGameThumbnail, createGameItem, deleteGameItem, deleteGame, listCustomItems, findUnusedCustomItems, findCustomItemsByIds, deleteCustomItems, listUsers, adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, } = require('../db') const { requireAdmin } = require('../middleware/auth') const router = express.Router() function buildUploadFilename(file) { const ext = path.extname(file.originalname || '').toLowerCase() const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : '' return `${Date.now()}-${nanoid()}${safeExt}` } const upload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'games')), filename: (req, file, cb) => cb(null, buildUploadFilename(file)), }), limits: { fileSize: 6 * 1024 * 1024 }, }) router.post('/games', requireAdmin, async (req, res) => { const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const exists = await findGameById(parsed.data.id) if (exists) return res.status(409).json({ error: 'game_id_taken' }) const game = await createGame({ id: parsed.data.id, name: parsed.data.name }) res.json({ game }) }) router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) const game = await findGameById(req.params.gameId) if (!game) return res.status(404).json({ error: 'not_found' }) const updated = await updateGameThumbnail(req.params.gameId, `/uploads/games/${req.file.filename}`) res.json({ game: updated }) }) router.post('/games/:gameId/images', requireAdmin, upload.single('image'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) const schema = z.object({ label: z.string().min(1).max(60) }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const game = await findGameById(req.params.gameId) if (!game) return res.status(404).json({ error: 'not_found' }) const item = await createGameItem({ id: nanoid(), gameId: game.id, src: `/uploads/games/${req.file.filename}`, label: parsed.data.label, }) res.json({ item }) }) router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { const game = await findGameById(req.params.gameId) if (!game) return res.status(404).json({ error: 'not_found' }) await deleteGameItem(req.params.itemId) res.json({ ok: true }) }) router.delete('/games/:gameId', requireAdmin, async (req, res) => { const game = await findGameById(req.params.gameId) if (!game) return res.status(404).json({ error: 'not_found' }) await deleteGame(req.params.gameId) res.json({ ok: true }) }) router.get('/custom-items', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(200).optional().default(50), orphanOnly: z .union([z.literal('true'), z.literal('false'), z.boolean()]) .optional() .default('false') .transform((value) => value === true || value === 'true'), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const result = await listCustomItems({ queryText: parsed.data.q, page: parsed.data.page, limit: parsed.data.limit, orphanOnly: parsed.data.orphanOnly, }) res.json(result) }) async function removeCustomItemFiles(items) { await Promise.all( items.map(async (item) => { if (!item?.src || !item.src.startsWith('/uploads/custom/')) return const absolutePath = path.join(__dirname, '..', '..', item.src.replace(/^\//, '')) try { await fs.unlink(absolutePath) } catch (e) { if (e?.code !== 'ENOENT') throw e } }) ) } router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false }) const target = result.items.find((item) => item.id === req.params.itemId) if (!target) return res.status(404).json({ error: 'not_found' }) if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) const items = await findCustomItemsByIds([target.id]) await deleteCustomItems([target.id]) await removeCustomItemFiles(items) res.json({ ok: true }) }) router.delete('/custom-items', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const items = await findUnusedCustomItems({ queryText: parsed.data.q }) const ids = items.map((item) => item.id) await deleteCustomItems(ids) await removeCustomItemFiles(items) res.json({ ok: true, deletedCount: ids.length }) }) router.get('/users', requireAdmin, async (req, res) => { const users = await listUsers() res.json({ users }) }) router.patch('/users/:userId', requireAdmin, async (req, res) => { const schema = z.object({ email: z.string().email(), nickname: z.string().trim().max(40).default(''), isAdmin: z.boolean(), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (req.params.userId === req.session.userId && !parsed.data.isAdmin) { return res.status(400).json({ error: 'self_admin_required' }) } const user = await findUserById(req.params.userId) if (!user) return res.status(404).json({ error: 'not_found' }) try { const updated = await adminUpdateUser({ id: user.id, email: parsed.data.email, nickname: parsed.data.nickname, isAdmin: parsed.data.isAdmin, }) res.json({ user: updated }) } catch (e) { if (e && e.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'email_taken' }) } throw e } }) router.delete('/users/:userId', requireAdmin, async (req, res) => { if (req.params.userId === req.session.userId) { return res.status(400).json({ error: 'cannot_delete_self' }) } const user = await findUserById(req.params.userId) if (!user) return res.status(404).json({ error: 'not_found' }) await adminDeleteUser(user.id) res.json({ ok: true }) }) router.patch('/users/:userId/password', requireAdmin, async (req, res) => { const schema = z.object({ password: z.string().min(6).max(120), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const user = await findUserById(req.params.userId) if (!user) return res.status(404).json({ error: 'not_found' }) const passwordHash = await bcrypt.hash(parsed.data.password, 10) await adminUpdateUserPassword({ id: user.id, passwordHash }) res.json({ ok: true }) }) module.exports = router