1057 lines
38 KiB
JavaScript
1057 lines
38 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,
|
|
findTopicById,
|
|
findTopicItemById,
|
|
listTopicItems,
|
|
findImageAssetById,
|
|
createTopic,
|
|
listTopics,
|
|
updateTopicThumbnail,
|
|
updateTopicVisibility,
|
|
createTopicItem,
|
|
updateTopicItemLabel,
|
|
updateTopicItemDisplayOrder,
|
|
countTierListsUsingTopicItem,
|
|
updateCustomItemLabel,
|
|
updateImageAssetLabel,
|
|
deleteTopicItem,
|
|
deleteTopic,
|
|
deleteTierList,
|
|
updateTopicDisplayOrder,
|
|
listCustomItems,
|
|
findCustomItemById,
|
|
findUnusedCustomItems,
|
|
findCustomItemsByIds,
|
|
deleteCustomItems,
|
|
listUsers,
|
|
findPrimaryAdminUser,
|
|
listAdminTierLists,
|
|
summarizeAdminTierLists,
|
|
findTierListById,
|
|
updateAdminTierListMeta,
|
|
listAdminTemplateRequests,
|
|
findTemplateRequestById,
|
|
updateTemplateRequestStatus,
|
|
updateTemplateRequestTargetTopic,
|
|
adminUpdateUser,
|
|
adminUpdateUserPassword,
|
|
adminDeleteUser,
|
|
listUnusedImageAssets,
|
|
deleteImageAssets,
|
|
getImageAssetStats,
|
|
listRecentImageOptimizationJobs,
|
|
clearImageOptimizationJobs,
|
|
cleanupMissingUploadReferences,
|
|
} = require('../db')
|
|
const { requireAdmin } = require('../middleware/auth')
|
|
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
|
|
|
const router = express.Router()
|
|
|
|
function getTemplateIdParam(req) {
|
|
return req.params.templateId || req.params.gameId || ''
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
function buildItemLabelFromSrc(src) {
|
|
const raw = typeof src === 'string' ? src : ''
|
|
const base = path.basename(raw.split('?')[0] || '', path.extname(raw.split('?')[0] || ''))
|
|
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 })
|
|
|
|
function decorateAdminUser(user, primaryAdmin) {
|
|
if (!user) return null
|
|
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
|
|
return {
|
|
...user,
|
|
isPrimaryAdmin,
|
|
isOperator: !!user.isAdmin && !isPrimaryAdmin,
|
|
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
|
|
}
|
|
}
|
|
|
|
async function getAdminUserContext(targetUserId, actingUserId) {
|
|
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
|
|
findUserById(targetUserId),
|
|
findUserById(actingUserId),
|
|
findPrimaryAdminUser(),
|
|
])
|
|
return { targetUser, actingUser, primaryAdmin }
|
|
}
|
|
|
|
function canManageAdminRole(actingUser, primaryAdmin) {
|
|
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
|
}
|
|
|
|
router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
id: z.string().min(1),
|
|
name: z.string().min(1).max(60),
|
|
isPublic: z.boolean().optional().default(false),
|
|
thumbnailSrc: z.string().max(255).optional().default(''),
|
|
})
|
|
const parsed = schema.safeParse(req.body)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
const exists = await findTopicById(parsed.data.id)
|
|
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
|
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
|
if (parsed.data.thumbnailSrc) {
|
|
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
|
await updateTopicThumbnail(template.id, copiedThumb)
|
|
}
|
|
const savedTemplate = await findTopicById(template.id)
|
|
res.json({ template: savedTemplate })
|
|
})
|
|
|
|
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
isPublic: z.boolean(),
|
|
})
|
|
const parsed = schema.safeParse(req.body)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
|
|
const templateId = getTemplateIdParam(req)
|
|
const template = await findTopicById(templateId)
|
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
|
|
res.json({ template: updated })
|
|
})
|
|
|
|
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
topicIds: 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 templates = await listTopics('', { includePrivate: true })
|
|
const validTopicIds = new Set(templates.map((template) => template.id))
|
|
const filteredIds = parsed.data.topicIds.filter((topicId) => validTopicIds.has(topicId))
|
|
const updatedTemplates = await updateTopicDisplayOrder(filteredIds)
|
|
res.json({ templates: updatedTemplates })
|
|
})
|
|
|
|
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
itemIds: z.array(z.string().min(1)).min(1),
|
|
})
|
|
const parsed = schema.safeParse(req.body)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
|
|
const templateId = getTemplateIdParam(req)
|
|
const template = await findTopicById(templateId)
|
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const items = await updateTopicItemDisplayOrder(template.id, parsed.data.itemIds)
|
|
res.json({ items })
|
|
})
|
|
|
|
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
|
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
|
const templateId = getTemplateIdParam(req)
|
|
const template = await findTopicById(templateId)
|
|
if (!template) 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 updateTopicThumbnail(templateId, optimized.src)
|
|
res.json({ template: updated })
|
|
})
|
|
|
|
router.post(['/games/:gameId/images', '/templates/:templateId/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 templateId = getTemplateIdParam(req)
|
|
const template = await findTopicById(templateId)
|
|
if (!template) 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 createTopicItem({
|
|
id: nanoid(),
|
|
topicId: template.id,
|
|
src: optimized.src,
|
|
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
|
})
|
|
})
|
|
)
|
|
|
|
res.json({ item: items[0], items })
|
|
})
|
|
|
|
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
|
const template = await findTopicById(getTemplateIdParam(req))
|
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
|
await deleteTopicItem(req.params.itemId)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => {
|
|
const template = await findTopicById(getTemplateIdParam(req))
|
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
|
const item = await findTopicItemById(req.params.itemId)
|
|
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
|
const usage = await countTierListsUsingTopicItem(req.params.itemId)
|
|
res.json({ usage })
|
|
})
|
|
|
|
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/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 template = await findTopicById(getTemplateIdParam(req))
|
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
|
|
if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
|
res.json({ item: updated })
|
|
})
|
|
|
|
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
|
const templateId = getTemplateIdParam(req)
|
|
const template = await findTopicById(templateId)
|
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
|
await deleteTopic(templateId)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
label: z.string().trim().min(1).max(60),
|
|
sourceType: z.enum(['template', 'user']).optional().default('user'),
|
|
})
|
|
const parsed = schema.safeParse(req.body)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
|
|
const itemId = req.params.itemId
|
|
if (itemId.startsWith('asset:')) {
|
|
const updated = await updateImageAssetLabel(itemId.slice(6), parsed.data.label)
|
|
if (!updated) return res.status(404).json({ error: 'not_found' })
|
|
return res.json({ item: updated })
|
|
}
|
|
|
|
if (parsed.data.sourceType === 'template') {
|
|
const updated = await updateTopicItemLabel(itemId, parsed.data.label)
|
|
if (!updated) return res.status(404).json({ error: 'not_found' })
|
|
return res.json({ item: updated })
|
|
}
|
|
|
|
const updated = await updateCustomItemLabel(itemId, parsed.data.label)
|
|
if (!updated) return res.status(404).json({ error: 'not_found' })
|
|
return res.json({ item: updated })
|
|
})
|
|
|
|
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),
|
|
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
|
|
})
|
|
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,
|
|
filterMode: parsed.data.filter,
|
|
})
|
|
res.json(result)
|
|
})
|
|
|
|
router.get('/tierlists', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
q: z.string().trim().max(120).optional().default(''),
|
|
topicId: 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,
|
|
topicId: parsed.data.topicId,
|
|
page: parsed.data.page,
|
|
limit: parsed.data.limit,
|
|
currentUserId: req.session?.userId || '',
|
|
})
|
|
res.json(result)
|
|
})
|
|
|
|
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
q: z.string().trim().max(120).optional().default(''),
|
|
topicId: 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 result = await summarizeAdminTierLists({
|
|
queryText: parsed.data.q,
|
|
topicId: parsed.data.topicId,
|
|
})
|
|
res.json(result)
|
|
})
|
|
|
|
router.get('/template-requests', requireAdmin, async (req, res) => {
|
|
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
|
|
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 })
|
|
})
|
|
|
|
router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => {
|
|
const result = await cleanupMissingUploadReferences()
|
|
res.json({ result })
|
|
})
|
|
|
|
async function removeUploadFiles(srcs) {
|
|
await Promise.all(
|
|
(srcs || []).map(async (src) => {
|
|
if (!src || !src.startsWith('/uploads/')) return
|
|
const absolutePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
|
|
try {
|
|
await fs.unlink(absolutePath)
|
|
} catch (e) {
|
|
if (e?.code !== 'ENOENT') throw e
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
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 promoteLibraryItemToTemplateItem({ item, templateId }) {
|
|
return createTopicItem({
|
|
id: nanoid(),
|
|
topicId: templateId,
|
|
src: item.src || '',
|
|
label: item.label,
|
|
})
|
|
}
|
|
|
|
async function copyUploadIntoGameAsset(src) {
|
|
if (typeof src !== 'string') return ''
|
|
const raw = src.trim()
|
|
if (!raw) return ''
|
|
|
|
if (raw.startsWith('/uploads/')) {
|
|
if (raw.startsWith('/uploads/assets/')) return raw
|
|
return raw
|
|
}
|
|
|
|
try {
|
|
const url = new URL(raw)
|
|
if (url.pathname.startsWith('/uploads/')) {
|
|
return url.pathname
|
|
}
|
|
} catch (error) {
|
|
return raw
|
|
}
|
|
|
|
return raw
|
|
}
|
|
|
|
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 promoteTierListItemsToTemplate({ tierList, templateId, 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 createTopicItem({
|
|
id: nanoid(),
|
|
topicId: templateId,
|
|
src: copiedSrc,
|
|
label: item.label,
|
|
})
|
|
)
|
|
}
|
|
|
|
return createdItems
|
|
}
|
|
|
|
async function promoteSnapshotItemsToTemplate({ items, templateId }) {
|
|
const existingItems = await listTopicItems(templateId)
|
|
const existingSrcs = new Set(
|
|
existingItems
|
|
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
|
|
.filter(Boolean)
|
|
)
|
|
const createdItems = []
|
|
|
|
for (const item of items || []) {
|
|
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
|
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
|
|
createdItems.push(
|
|
await createTopicItem({
|
|
id: nanoid(),
|
|
topicId: templateId,
|
|
src: copiedSrc,
|
|
label: item.label,
|
|
})
|
|
)
|
|
existingSrcs.add(copiedSrc)
|
|
}
|
|
|
|
return createdItems
|
|
}
|
|
|
|
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}, itemSrcs = []) {
|
|
const requestedIds = new Set((itemIds || []).filter(Boolean))
|
|
const requestedSrcs = new Set((itemSrcs || []).filter((src) => typeof src === 'string' && src.trim()).map((src) => src.trim()))
|
|
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
|
|
const filtered =
|
|
requestedIds.size || requestedSrcs.size
|
|
? items.filter((item) => (item?.id && requestedIds.has(item.id)) || (typeof item?.src === 'string' && requestedSrcs.has(item.src.trim())))
|
|
: items
|
|
return filtered
|
|
.filter((item) => typeof item?.src === 'string' && item.src.trim())
|
|
.map((item) => {
|
|
const draftLabel =
|
|
typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim()
|
|
? itemLabels[item.id].trim().slice(0, 60)
|
|
: typeof item?.label === 'string' && item.label.trim()
|
|
? item.label.trim().slice(0, 60)
|
|
: buildItemLabelFromSrc(item.src)
|
|
|
|
return {
|
|
...item,
|
|
src: item.src.trim(),
|
|
label: draftLabel,
|
|
}
|
|
})
|
|
}
|
|
|
|
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
|
|
await createTopic({ id: templateId, name: templateName, isPublic: false })
|
|
if (tierList.thumbnailSrc) {
|
|
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
|
|
await updateTopicThumbnail(templateId, copiedThumb)
|
|
}
|
|
|
|
const createdItems = []
|
|
for (const item of uniqueTierListPoolItems(tierList)) {
|
|
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
|
createdItems.push(
|
|
await createTopicItem({
|
|
id: nanoid(),
|
|
topicId: templateId,
|
|
src: copiedSrc,
|
|
label: item.label,
|
|
})
|
|
)
|
|
}
|
|
|
|
return { game: await findTopicById(templateId), items: createdItems }
|
|
}
|
|
|
|
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
|
|
await createTopic({ id: templateId, name: templateName, isPublic: false })
|
|
|
|
if (templateRequest.thumbnailSrc) {
|
|
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
|
|
await updateTopicThumbnail(templateId, copiedThumb)
|
|
}
|
|
|
|
const items = await promoteSnapshotItemsToTemplate({
|
|
items: templateRequest.items || [],
|
|
templateId,
|
|
})
|
|
|
|
return { game: await findTopicById(templateId), items }
|
|
}
|
|
|
|
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
|
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
|
|
const target = result.items.find((item) => item.id === req.params.itemId)
|
|
if (!target) return res.status(404).json({ error: 'not_found' })
|
|
if (target.sourceType === 'template') {
|
|
if (String(target.id || '').startsWith('asset:')) {
|
|
const assetId = String(target.id).slice('asset:'.length)
|
|
const asset = await findImageAssetById(assetId)
|
|
if (!asset) return res.status(404).json({ error: 'not_found' })
|
|
await deleteImageAssets([assetId])
|
|
await removeUploadFiles([asset.src])
|
|
return res.json({ ok: true, sourceType: 'template-asset' })
|
|
}
|
|
|
|
await deleteTopicItem(target.id)
|
|
return res.json({ ok: true, sourceType: 'template' })
|
|
}
|
|
|
|
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
|
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
|
|
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, sourceType: 'user' })
|
|
})
|
|
|
|
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
topicId: z.string().min(1),
|
|
})
|
|
const parsed = schema.safeParse(req.body)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
|
|
const template = await findTopicById(parsed.data.topicId)
|
|
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
|
|
|
const customItem = await findCustomItemById(req.params.itemId)
|
|
const templateItem = customItem ? null : await findTopicItemById(req.params.itemId)
|
|
const assetItemId = String(req.params.itemId || '')
|
|
const imageAsset = !customItem && !templateItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
|
|
const sourceItem =
|
|
customItem ||
|
|
templateItem ||
|
|
(imageAsset
|
|
? {
|
|
src: imageAsset.src || '',
|
|
label: imageAsset.labelOverride || path.basename(imageAsset.src || '', path.extname(imageAsset.src || '')) || 'item',
|
|
}
|
|
: null)
|
|
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const item = await promoteLibraryItemToTemplateItem({ item: sourceItem, templateId: template.id })
|
|
res.json({ item })
|
|
})
|
|
|
|
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
topicId: 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 template = await findTopicById(parsed.data.topicId)
|
|
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
|
|
|
const tierList = await findTierListById(req.params.tierListId)
|
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const items = await promoteTierListItemsToTemplate({
|
|
tierList,
|
|
templateId: template.id,
|
|
itemIds: parsed.data.itemIds,
|
|
})
|
|
res.json({ items })
|
|
})
|
|
|
|
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
topicId: 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 findTopicById(parsed.data.topicId)
|
|
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
|
|
|
const tierList = await findTierListById(req.params.tierListId)
|
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const result = await createTemplateFromTierList({
|
|
tierList: {
|
|
...tierList,
|
|
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
|
|
},
|
|
templateId: parsed.data.topicId,
|
|
templateName: parsed.data.name,
|
|
})
|
|
res.json(result)
|
|
})
|
|
|
|
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
title: z.string().trim().min(1).max(120),
|
|
description: z.string().max(500).optional().default(''),
|
|
isPublic: z.boolean(),
|
|
})
|
|
const parsed = schema.safeParse(req.body)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
|
|
const tierList = await findTierListById(req.params.tierListId)
|
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const updated = await updateAdminTierListMeta({
|
|
id: tierList.id,
|
|
title: parsed.data.title,
|
|
description: parsed.data.description || '',
|
|
isPublic: parsed.data.isPublic,
|
|
})
|
|
res.json({ tierList: updated })
|
|
})
|
|
|
|
router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
|
const tierList = await findTierListById(req.params.tierListId)
|
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
|
await deleteTierList(tierList.id)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
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 targetTopicId = templateRequest.targetTopicId || templateRequest.sourceTopicId
|
|
const template = await findTopicById(targetTopicId)
|
|
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
|
|
|
const items = await promoteSnapshotItemsToTemplate({
|
|
items: templateRequest.items || [],
|
|
templateId: template.id,
|
|
})
|
|
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
|
return res.json({ request, items })
|
|
}
|
|
|
|
const schema = z.object({
|
|
topicId: 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 findTopicById(parsed.data.topicId)
|
|
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
|
|
|
const result = await createTemplateFromRequest({
|
|
templateRequest,
|
|
templateId: parsed.data.topicId,
|
|
templateName: parsed.data.name,
|
|
})
|
|
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
|
res.json({ request, ...result })
|
|
})
|
|
|
|
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
|
|
let templateRequest = await findTemplateRequestById(req.params.requestId)
|
|
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
|
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
|
return res.status(409).json({ error: 'request_already_handled' })
|
|
}
|
|
|
|
if (templateRequest.type === 'create' && templateRequest.targetTopicId && !templateRequest.targetTopicName) {
|
|
templateRequest = await updateTemplateRequestTargetTopic({
|
|
id: templateRequest.id,
|
|
targetTopicId: '',
|
|
})
|
|
}
|
|
|
|
if (templateRequest.status === 'reviewing') {
|
|
return res.json({ request: templateRequest })
|
|
}
|
|
|
|
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
|
|
res.json({ request })
|
|
})
|
|
|
|
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
topicId: 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 templateRequest = await findTemplateRequestById(req.params.requestId)
|
|
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
|
if (templateRequest.type !== 'create') return res.status(409).json({ error: 'create_request_required' })
|
|
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
|
|
return res.status(409).json({ error: 'request_already_handled' })
|
|
}
|
|
|
|
const template = await findTopicById(parsed.data.topicId)
|
|
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
|
|
|
const request = await updateTemplateRequestTargetTopic({
|
|
id: templateRequest.id,
|
|
targetTopicId: template.id,
|
|
})
|
|
res.json({ request })
|
|
})
|
|
|
|
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
|
|
const schema = z.object({
|
|
topicId: z.string().trim().min(1).max(120),
|
|
itemIds: z.array(z.string().min(1)).optional().default([]),
|
|
itemSrcs: z.array(z.string().min(1)).optional().default([]),
|
|
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
|
|
})
|
|
const parsed = schema.safeParse(req.body)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
|
|
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
|
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
|
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
|
|
return res.status(409).json({ error: 'request_already_handled' })
|
|
}
|
|
|
|
const template = await findTopicById(parsed.data.topicId)
|
|
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
|
|
|
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
|
|
if (!promotableItems.length) {
|
|
return res.status(400).json({ error: 'no_items_selected' })
|
|
}
|
|
|
|
let items = []
|
|
try {
|
|
items = await promoteSnapshotItemsToTemplate({
|
|
items: promotableItems,
|
|
templateId: template.id,
|
|
})
|
|
} catch (error) {
|
|
console.error('[admin] template request promote-items failed', {
|
|
requestId: templateRequest.id,
|
|
topicId: template.id,
|
|
itemCount: promotableItems.length,
|
|
message: error?.message || 'unknown_error',
|
|
code: error?.code || '',
|
|
stack: error?.stack || '',
|
|
})
|
|
return res.status(500).json({
|
|
error: 'promote_items_failed',
|
|
detail: error?.message || 'unknown_error',
|
|
code: error?.code || '',
|
|
})
|
|
}
|
|
|
|
const request =
|
|
templateRequest.status === 'reviewing'
|
|
? templateRequest
|
|
: await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
|
|
|
|
res.json({ request, items })
|
|
})
|
|
|
|
router.post('/template-requests/:requestId/complete', requireAdmin, async (req, res) => {
|
|
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
|
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
|
if (templateRequest.status === 'completed') return res.json({ request: templateRequest })
|
|
if (templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
|
|
return res.status(409).json({ error: 'request_already_handled' })
|
|
}
|
|
|
|
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'completed' })
|
|
res.json({ request })
|
|
})
|
|
|
|
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 schema = z.object({
|
|
q: z.string().trim().max(120).optional().default(''),
|
|
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
|
|
direction: z.enum(['asc', 'desc']).optional().default('desc'),
|
|
})
|
|
const parsed = schema.safeParse(req.query)
|
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
|
|
|
const [users, primaryAdmin] = await Promise.all([
|
|
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
|
|
findPrimaryAdminUser(),
|
|
])
|
|
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
|
|
})
|
|
|
|
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' })
|
|
|
|
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
|
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
|
|
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
|
|
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
|
|
|
|
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
|
|
return res.status(400).json({ error: 'self_admin_required' })
|
|
}
|
|
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
|
|
return res.status(403).json({ error: 'primary_admin_protected' })
|
|
}
|
|
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
|
|
return res.status(400).json({ error: 'primary_admin_required' })
|
|
}
|
|
if (roleChanged && !actingIsPrimaryAdmin) {
|
|
return res.status(403).json({ error: 'primary_admin_only' })
|
|
}
|
|
|
|
try {
|
|
const updated = await adminUpdateUser({
|
|
id: targetUser.id,
|
|
email: parsed.data.email,
|
|
nickname: parsed.data.nickname,
|
|
isAdmin: parsed.data.isAdmin,
|
|
})
|
|
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
|
} 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 { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
|
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
|
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
|
|
return res.status(403).json({ error: 'primary_admin_protected' })
|
|
}
|
|
|
|
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 || targetUser.avatarSrc || ''
|
|
const updated = await adminUpdateUser({
|
|
id: targetUser.id,
|
|
email: targetUser.email,
|
|
nickname: targetUser.nickname || '',
|
|
isAdmin: !!targetUser.isAdmin,
|
|
avatarSrc: nextAvatarSrc,
|
|
})
|
|
|
|
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
|
})
|
|
|
|
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 { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
|
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
|
|
|
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
|
|
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
|
|
if (targetIsPrimaryAdmin) {
|
|
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
|
|
}
|
|
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
|
|
return res.status(403).json({ error: 'primary_admin_only' })
|
|
}
|
|
|
|
await adminDeleteUser(targetUser.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 { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
|
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
|
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
|
|
return res.status(403).json({ error: 'primary_admin_protected' })
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
|
|
await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
module.exports = router
|