Files
tier-maker/backend/src/routes/admin.js

219 lines
7.3 KiB
JavaScript

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