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, listGames, updateGameThumbnail, createGameItem, updateGameItemLabel, deleteGameItem, deleteGame, updateGameDisplayOrder, listCustomItems, findCustomItemById, findUnusedCustomItems, findCustomItemsByIds, deleteCustomItems, listUsers, listAdminTierLists, findTierListById, listAdminTemplateRequests, findTemplateRequestById, updateTemplateRequestStatus, 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}` } function buildItemLabelFromFilename(file) { const originalName = file?.originalname || '' const base = path.basename(originalName, path.extname(originalName)) const normalized = base .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 60) return normalized || 'item' } 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 }, }) const avatarUpload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')), filename: (req, file, cb) => cb(null, buildUploadFilename(file)), }), limits: { fileSize: 3 * 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.patch('/games/display-order', requireAdmin, async (req, res) => { const schema = z.object({ gameIds: z.array(z.string().min(1)).max(50), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const games = await listGames() const validGameIds = new Set(games.map((game) => game.id)) const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId)) const updatedGames = await updateGameDisplayOrder(filteredIds) res.json({ games: updatedGames }) }) 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.array('images', 50), async (req, res) => { const files = Array.isArray(req.files) ? req.files : [] if (!files.length) 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 manualLabel = typeof req.body?.label === 'string' ? req.body.label.trim() : '' if (manualLabel && manualLabel.length > 60) return res.status(400).json({ error: 'bad_request' }) const items = await Promise.all( files.map((file, index) => createGameItem({ id: nanoid(), gameId: game.id, src: `/uploads/games/${file.filename}`, label: index === 0 && manualLabel ? manualLabel : buildItemLabelFromFilename(file), }) ) ) res.json({ item: items[0], items }) }) 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.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { const schema = z.object({ label: z.string().trim().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 updated = await updateGameItemLabel(req.params.itemId, parsed.data.label) if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' }) res.json({ item: updated }) }) 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) }) router.get('/tierlists', 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), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const result = await listAdminTierLists({ queryText: parsed.data.q, page: parsed.data.page, limit: parsed.data.limit, currentUserId: req.session?.userId || '', }) res.json(result) }) router.get('/template-requests', requireAdmin, async (req, res) => { const requests = await listAdminTemplateRequests({ status: 'pending' }) res.json({ requests }) }) 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 } }) ) } async function promoteCustomItemToGameItem({ customItem, gameId }) { const originalName = path.basename(customItem.src || '') const nextFilename = buildUploadFilename({ originalname: originalName }) const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, '')) const targetRelativePath = path.join('uploads', 'games', nextFilename) const targetPath = path.join(__dirname, '..', '..', targetRelativePath) await fs.copyFile(sourcePath, targetPath) return createGameItem({ id: nanoid(), gameId, src: `/${targetRelativePath.replace(/\\/g, '/')}`, label: customItem.label, }) } async function copyUploadIntoGameAsset(src) { if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || '' const originalName = path.basename(src) const nextFilename = buildUploadFilename({ originalname: originalName }) const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, '')) const targetRelativePath = path.join('uploads', 'games', nextFilename) const targetPath = path.join(__dirname, '..', '..', targetRelativePath) await fs.copyFile(sourcePath, targetPath) return `/${targetRelativePath.replace(/\\/g, '/')}` } function uniqueTierListPoolItems(tierList) { const seen = new Set() return (tierList?.pool || []).filter((item) => { if (!item?.id || seen.has(item.id)) return false seen.add(item.id) return true }) } async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) { const allowedIds = new Set((itemIds || []).filter(Boolean)) const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom') const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems const createdItems = [] for (const item of itemsToCopy) { const copiedSrc = await copyUploadIntoGameAsset(item.src) createdItems.push( await createGameItem({ id: nanoid(), gameId, src: copiedSrc, label: item.label, }) ) } return createdItems } async function promoteSnapshotItemsToGame({ items, gameId }) { const createdItems = [] for (const item of items || []) { const copiedSrc = await copyUploadIntoGameAsset(item.src) createdItems.push( await createGameItem({ id: nanoid(), gameId, src: copiedSrc, label: item.label, }) ) } return createdItems } async function createGameTemplateFromTierList({ tierList, gameId, gameName }) { await createGame({ id: gameId, name: gameName }) if (tierList.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) await updateGameThumbnail(gameId, copiedThumb) } const createdItems = [] for (const item of uniqueTierListPoolItems(tierList)) { const copiedSrc = await copyUploadIntoGameAsset(item.src) createdItems.push( await createGameItem({ id: nanoid(), gameId, src: copiedSrc, label: item.label, }) ) } return { game: await findGameById(gameId), items: createdItems } } async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) { await createGame({ id: gameId, name: gameName }) if (templateRequest.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) await updateGameThumbnail(gameId, copiedThumb) } const items = await promoteSnapshotItemsToGame({ items: templateRequest.items || [], gameId, }) return { game: await findGameById(gameId), items } } 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.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { const schema = z.object({ gameId: z.string().min(1), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const game = await findGameById(parsed.data.gameId) if (!game) return res.status(404).json({ error: 'game_not_found' }) const customItem = await findCustomItemById(req.params.itemId) if (!customItem) return res.status(404).json({ error: 'not_found' }) const item = await promoteCustomItemToGameItem({ customItem, gameId: game.id }) res.json({ item }) }) router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => { const schema = z.object({ gameId: z.string().min(1), itemIds: z.array(z.string().min(1)).optional().default([]), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const game = await findGameById(parsed.data.gameId) if (!game) return res.status(404).json({ error: 'game_not_found' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) const items = await promoteTierListItemsToGame({ tierList, gameId: game.id, itemIds: parsed.data.itemIds, }) res.json({ items }) }) router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => { const schema = z.object({ gameId: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120), itemIds: z.array(z.string().min(1)).optional().default([]), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const exists = await findGameById(parsed.data.gameId) if (exists) return res.status(409).json({ error: 'game_id_taken' }) const tierList = await findTierListById(req.params.tierListId) if (!tierList) return res.status(404).json({ error: 'not_found' }) const result = await createGameTemplateFromTierList({ tierList: { ...tierList, pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, }, gameId: parsed.data.gameId, gameName: parsed.data.name, }) res.json(result) }) router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => { const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' }) if (templateRequest.type === 'update') { const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId const game = await findGameById(targetGameId) if (!game) return res.status(404).json({ error: 'game_not_found' }) const items = await promoteSnapshotItemsToGame({ items: templateRequest.items || [], gameId: game.id, }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) return res.json({ request, items }) } const schema = z.object({ gameId: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const exists = await findGameById(parsed.data.gameId) if (exists) return res.status(409).json({ error: 'game_id_taken' }) const result = await createGameTemplateFromRequest({ templateRequest, gameId: parsed.data.gameId, gameName: parsed.data.name, }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) res.json({ request, ...result }) }) router.post('/template-requests/:requestId/reject', requireAdmin, async (req, res) => { const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'rejected' }) res.json({ request }) }) 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.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar'), async (req, res) => { const schema = z.object({ removeAvatar: z.union([z.literal('1'), z.undefined()]).optional(), }) 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 shouldRemoveAvatar = parsed.data.removeAvatar === '1' const nextAvatarSrc = shouldRemoveAvatar ? '' : req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || '' const updated = await adminUpdateUser({ id: user.id, email: user.email, nickname: user.nickname || '', isAdmin: !!user.isAdmin, avatarSrc: nextAvatarSrc, }) res.json({ user: updated }) }) 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