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, listUnusedImageAssets, deleteImageAssets, getImageAssetStats, listRecentImageOptimizationJobs, clearImageOptimizationJobs, } = require('../db') const { requireAdmin } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') 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 = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 }) const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 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 optimized = await writeOptimizedImage({ file: req.file, directory: 'games', width: 1280, height: 1280, fit: 'inside', quality: 84, }) const updated = await updateGameThumbnail(req.params.gameId, optimized.src) 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 labelsRaw = req.body?.labels const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : [] const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : '')) if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' }) const items = await Promise.all( files.map(async (file, index) => { const optimized = await writeOptimizedImage({ file, directory: 'games', width: 512, height: 512, fit: 'inside', quality: 84, }) return createGameItem({ id: nanoid(), gameId: game.id, src: optimized.src, label: normalizedLabels[index] || 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 }) }) router.get('/image-assets/orphans', requireAdmin, async (req, res) => { const schema = z.object({ limit: z.coerce.number().int().min(1).max(500).optional().default(100), minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const assets = await listUnusedImageAssets(parsed.data) res.json({ assets }) }) async function removeImageAssetFiles(assets) { await Promise.all( (assets || []).map(async (asset) => { if (!asset?.src || !asset.src.startsWith('/uploads/')) return const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, '')) try { await fs.unlink(absolutePath) } catch (error) { if (error?.code !== 'ENOENT') throw error } }) ) } router.post('/image-assets/cleanup', requireAdmin, async (req, res) => { const schema = z.object({ limit: z.coerce.number().int().min(1).max(500).optional().default(100), minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const assets = await listUnusedImageAssets(parsed.data) const deleted = await deleteImageAssets(assets.map((asset) => asset.id)) await removeImageAssetFiles(deleted) res.json({ deletedCount: deleted.length, assets: deleted }) }) router.get('/image-assets/stats', requireAdmin, async (req, res) => { const schema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/).optional(), limit: z.coerce.number().int().min(1).max(24).optional().default(12), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const filters = { month: parsed.data.month } const [stats, recentJobs] = await Promise.all([ getImageAssetStats(filters), listRecentImageOptimizationJobs(parsed.data.limit, filters), ]) res.json({ stats, filters, queue: getImageOptimizationQueueState(), recentJobs, }) }) router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => { const schema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(), }) const parsed = schema.safeParse(req.body || {}) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined }) res.json({ deletedCount }) }) 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 }) { return createGameItem({ id: nanoid(), gameId, src: customItem.src || '', label: customItem.label, }) } async function copyUploadIntoGameAsset(src) { if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || '' if (src.startsWith('/uploads/assets/')) return src return src } 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 optimized = req.file ? await writeOptimizedImage({ file: req.file, directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82, }) : null const shouldRemoveAvatar = parsed.data.removeAvatar === '1' const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || 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