556 lines
19 KiB
JavaScript
556 lines
19 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,
|
|
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
|