Everything the extraction needed from core that ctx did not already offer. Additions only, so minor. ctx.activity.log, because an admin action a module performs has to land in core's one audit log or the trail has a hole exactly where a module operates the game -- a module keeping its own log would be a second place to look, which in practice means a place nobody looks. Write-only; reading the log is the admin panel's job and it spans every actor. ctx.users.getById, one function for one caller: the admin.users.detail slot router needs the user its prefix names. ctx.site.baseUrl, because a module has to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a getter, not a captured string, so it cannot go stale against the env. ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed over whole. The split is deliberate: a module states its own window and cap because it knows what its endpoints cost, and takes the plumbing from core so there is one express-rate-limit in the process and one place a breach is logged. accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit behind the same counter -- so a module's account-change route has to land IN it rather than beside it. marketLimiter was UO policy living in core's file and leaves with the route it guards. registerPostHook is the fourth registry, and the last thing binding core to the module. Core's post controller called newsGump.syncPost directly: core's CMS naming a UO file. It now publishes what it already knows and a subscriber decides what to do with it. Not folded into registerAnnounceLeg, which fires on the same transition, because a leg is a one-shot DELIVERY with retry and classification while a post hook maintains idempotent STATE, runs on delete as well as save, and refreshes silently on an edit. Also fixes a real loader defect the extraction exposed: schema table names were matched against the RAW file, so a fragment whose header says "every CREATE TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table called `carries`. module-uo's fragment hit exactly that. Both scans now read split statements, which strip comments -- the same class of bug as a boundary check failing on its own documentation. Co-Authored-By: Claude <noreply@anthropic.com>
972 lines
39 KiB
JavaScript
972 lines
39 KiB
JavaScript
const fs = require('fs')
|
|
|
|
const posts = require('../../../model/posts/posts.model')
|
|
const wiki = require('../../../model/wiki/wiki.model')
|
|
const settings = require('../../../model/settings/settings.model')
|
|
const users = require('../../../model/users/users.model')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
|
|
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
|
const registries = require('../../../modules/registries')
|
|
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
|
const pushDispatch = require('../../../utils/pushDispatch')
|
|
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
|
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
|
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
|
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
|
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
|
|
const htmlShell = require('../../../utils/htmlShell')
|
|
|
|
const log = require('../../../utils/logger')('admin')
|
|
|
|
// Fire the announcement pipeline the moment a post transitions INTO
|
|
// "published news" — a false→true publish while in news, or a category change
|
|
// into news while already published. Enqueues one announce_jobs row whose two
|
|
// legs (in-game town crier + Discord #news) are then delivered with independent
|
|
// retry by the dispatcher worker (utils/announceWorker). Fire-and-forget and
|
|
// self-guarding (enqueueIfNeeded never throws and de-dupes via the post's
|
|
// existing announce_job_id) so a pipeline hiccup never breaks saving a post.
|
|
// Awaited (not fire-and-forget) because enqueue is purely local DB work — one
|
|
// INSERT + a back-pointer UPDATE, no network — so it never blocks on the sidecar
|
|
// or Discord (that happens later in the worker). Awaiting keeps the de-dup guard
|
|
// (post.announce_job_id) reliable against rapid double-publishes. Still guarded:
|
|
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
|
|
async function announceIfNewlyPublished(post, transition) {
|
|
// enqueueIfNeeded returns a truthy job id EXACTLY on a real transition into
|
|
// published news (and null on an edit/re-publish or a hiccup) — reuse that as
|
|
// the single "newly published news" signal for the push too, so we never
|
|
// double-fire on edits or replicate the transition logic.
|
|
const jobId = await announceJobs.enqueueIfNeeded(post, transition)
|
|
// Tell whoever is listening that a post was saved, and what the transition
|
|
// was. Core's CMS is the only writer of posts, and a module may mirror one
|
|
// somewhere core knows nothing about — module-uo keeps UO's in-game Town Cryer
|
|
// News gump in step this way. Awaited but never throwing, so a subscriber's
|
|
// sidecar hiccup cannot break saving a post: the same guarantee the enqueue
|
|
// above gives.
|
|
await registries.dispatchPostHook('onSaved', { post, transition })
|
|
// Opt-in push tickle to news.post subscribers, on the same transition.
|
|
// Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving.
|
|
if (jobId) {
|
|
Promise.resolve(pushDispatch.publish('news.post', { ref: String(post.id) })).catch((err) =>
|
|
log.warn('news push failed', { postId: post.id, message: err.message }),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── Dashboard & site mode ─────────────────────────────────────────────
|
|
async function dashboard(req, res) {
|
|
try {
|
|
return res.json({
|
|
site_mode: (await settings.get('site_mode')) || 'live',
|
|
last_change: {
|
|
at: await settings.get('site_mode_changed_at'),
|
|
by: await settings.get('site_mode_changed_by'),
|
|
},
|
|
counts: {
|
|
posts: await posts.counts(),
|
|
users: await users.count(),
|
|
},
|
|
recent_activity: await activity.list({ limit: 10 }),
|
|
})
|
|
} catch (err) {
|
|
log.error('dashboard', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function setSiteMode(req, res) {
|
|
const { mode } = req.body
|
|
try {
|
|
const changedAt = new Date().toISOString()
|
|
await settings.setMany(
|
|
{
|
|
site_mode: mode,
|
|
site_mode_changed_at: changedAt,
|
|
site_mode_changed_by: req.user.username,
|
|
},
|
|
req.user.id,
|
|
)
|
|
await activity.log({ req, action: 'site_mode.change', detail: { mode } })
|
|
log.info('site mode changed', { mode, by: req.user.username, ip: req.ip })
|
|
return res.json({ site_mode: mode, changed_at: changedAt, changed_by: req.user.username })
|
|
} catch (err) {
|
|
log.error('setSiteMode', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Posts ─────────────────────────────────────────────────────────────
|
|
async function listPosts(req, res) {
|
|
try {
|
|
let dbCategory = null
|
|
if (req.query.category) {
|
|
dbCategory = posts.toDbCategory(req.query.category)
|
|
if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
|
|
}
|
|
return res.json(await posts.listAll(dbCategory))
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getPost(req, res) {
|
|
try {
|
|
const post = await posts.getById(Number(req.params.id))
|
|
if (!post) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(post)
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function createPost(req, res) {
|
|
const dbCategory = posts.toDbCategory(req.body.category)
|
|
if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
|
|
if (dbCategory === 'screenshot' && !req.body.image_url) {
|
|
return res.status(400).json({ message: 'Screenshots require an image_url' })
|
|
}
|
|
try {
|
|
const created = await posts.create({
|
|
category: dbCategory,
|
|
title: req.body.title,
|
|
slug: req.body.slug || null,
|
|
excerpt: req.body.excerpt || null,
|
|
body: req.body.body || null,
|
|
image_url: req.body.image_url || null,
|
|
published: Boolean(req.body.published),
|
|
author_id: req.user.id,
|
|
})
|
|
await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } })
|
|
await announceIfNewlyPublished(created, { wasPublished: false, wasNews: false })
|
|
return res.status(201).json(created)
|
|
} catch (err) {
|
|
log.error('createPost', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function updatePost(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const current = await posts.getById(id)
|
|
if (!current) return res.status(404).json({ message: 'Not found' })
|
|
|
|
const fields = {}
|
|
for (const key of ['title', 'slug', 'excerpt', 'body', 'image_url']) {
|
|
if (key in req.body) fields[key] = req.body[key] || null
|
|
}
|
|
if ('category' in req.body) {
|
|
const dbCategory = posts.toDbCategory(req.body.category)
|
|
if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
|
|
fields.category = dbCategory
|
|
}
|
|
if ('published' in req.body) {
|
|
fields.published = req.body.published ? 1 : 0
|
|
if (req.body.published && !current.published_at) fields.published_at = new Date()
|
|
}
|
|
|
|
const updated = await posts.update(id, fields)
|
|
await activity.log({ req, action: 'post.update', detail: { id } })
|
|
await announceIfNewlyPublished(updated, {
|
|
wasPublished: Boolean(current.published),
|
|
wasNews: current.category === 'news',
|
|
})
|
|
return res.json(updated)
|
|
} catch (err) {
|
|
log.error('updatePost', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function publishPost(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const current = await posts.getById(id)
|
|
if (!current) return res.status(404).json({ message: 'Not found' })
|
|
const updated = await posts.setPublished(id, Boolean(req.body.published))
|
|
await activity.log({
|
|
req,
|
|
action: 'post.publish',
|
|
detail: { id, published: Boolean(req.body.published) },
|
|
})
|
|
await announceIfNewlyPublished(updated, {
|
|
wasPublished: Boolean(current.published),
|
|
wasNews: current.category === 'news',
|
|
})
|
|
return res.json(updated)
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function deletePost(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const current = await posts.getById(id)
|
|
await posts.remove(id)
|
|
await activity.log({ req, action: 'post.delete', detail: { id } })
|
|
// And that it is gone. A subscriber decides for itself whether it was
|
|
// mirroring this one — core does not know, and asking would mean core
|
|
// holding a predicate that belongs to the subscriber (`inGump` used to live
|
|
// right here, and it was UO's question, not the CMS's).
|
|
await registries.dispatchPostHook('onDeleted', { post: current, id })
|
|
return res.json({ id })
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/posts/:id/announce — the announcement job for a post (or null if it
|
|
// was never announced), for the status panel on the post editor.
|
|
async function getAnnounceStatus(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const job = await announceJobs.getByPostId(id)
|
|
return res.json(job || null)
|
|
} catch (err) {
|
|
log.error('getAnnounceStatus', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// POST /admin/posts/:id/announce/retry — reset one delivery leg to pending so
|
|
// the dispatcher re-attempts it (e.g. after fixing the news channel / sidecar).
|
|
async function retryAnnounceLeg(req, res) {
|
|
const id = Number(req.params.id)
|
|
const leg = req.body.leg
|
|
try {
|
|
const job = await announceJobs.resetLeg(id, leg)
|
|
if (!job) return res.status(404).json({ message: 'No announcement job for this post' })
|
|
await activity.log({ req, action: 'post.announce.retry', detail: { id, leg } })
|
|
return res.json(job)
|
|
} catch (err) {
|
|
log.error('retryAnnounceLeg', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function uploadImage(req, res) {
|
|
if (!req.file) return res.status(400).json({ message: 'No image uploaded' })
|
|
const imageUrl = `/uploads/${req.file.filename}`
|
|
await activity.log({ req, action: 'post.upload', detail: { image_url: imageUrl } })
|
|
return res.status(201).json({ image_url: imageUrl })
|
|
}
|
|
|
|
// Generalized upload used by rich-text editors (wiki, etc.). Same multer config
|
|
// as the screenshot upload; returns a neutral { url }.
|
|
async function uploadFile(req, res) {
|
|
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
|
const url = `/uploads/${req.file.filename}`
|
|
await activity.log({ req, action: 'upload', detail: { url } })
|
|
return res.status(201).json({ url })
|
|
}
|
|
|
|
// ── Wiki pages ─────────────────────────────────────────────────────────
|
|
async function listWiki(req, res) {
|
|
try {
|
|
const q = (req.query.q || '').trim()
|
|
if (q) return res.json(await wiki.search(q, { publishedOnly: false }))
|
|
|
|
const filters = {}
|
|
if (req.query.category) {
|
|
const category = await wiki.getCategoryBySlug(req.query.category)
|
|
filters.categoryId = category ? category.id : -1 // unknown → match nothing
|
|
}
|
|
if (req.query.tag) {
|
|
const tag = await wiki.getTagBySlug(req.query.tag)
|
|
filters.tagId = tag ? tag.id : -1 // unknown → match nothing
|
|
}
|
|
if (req.query.status === 'draft') filters.published = false
|
|
if (req.query.status === 'published') filters.published = true
|
|
return res.json(await wiki.listAll(filters))
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getWiki(req, res) {
|
|
try {
|
|
const page = await wiki.getBySlug(req.params.slug)
|
|
if (!page) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(page)
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Resolve a category_id from the request, validating it exists. Returns
|
|
// { ok, value } so the caller can distinguish "not provided" from "invalid".
|
|
async function resolveCategoryId(body) {
|
|
if (!('category_id' in body) || body.category_id == null || body.category_id === '') {
|
|
return { ok: true, value: null }
|
|
}
|
|
const category = await wiki.getCategoryById(Number(body.category_id))
|
|
if (!category) return { ok: false }
|
|
return { ok: true, value: category.id }
|
|
}
|
|
|
|
async function createWiki(req, res) {
|
|
try {
|
|
if (await wiki.getBySlug(req.body.slug)) {
|
|
return res.status(409).json({ message: 'A page with that slug already exists' })
|
|
}
|
|
const cat = await resolveCategoryId(req.body)
|
|
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
|
|
|
|
const page = await wiki.create({
|
|
slug: req.body.slug,
|
|
title: req.body.title,
|
|
body: req.body.body || null,
|
|
excerpt: req.body.excerpt || null,
|
|
categoryId: cat.value,
|
|
published: req.body.published !== false,
|
|
tags: Array.isArray(req.body.tags) ? req.body.tags : undefined,
|
|
updatedBy: req.user.id,
|
|
})
|
|
await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } })
|
|
return res.status(201).json(page)
|
|
} catch (err) {
|
|
log.error('createWiki', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function updateWiki(req, res) {
|
|
try {
|
|
const existing = await wiki.getBySlug(req.params.slug)
|
|
if (!existing) return res.status(404).json({ message: 'Not found' })
|
|
|
|
const input = { updatedBy: req.user.id }
|
|
if ('title' in req.body) input.title = req.body.title
|
|
if ('body' in req.body) input.body = req.body.body || null
|
|
if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null
|
|
if ('published' in req.body) input.published = Boolean(req.body.published)
|
|
if ('change_note' in req.body) input.changeNote = req.body.change_note
|
|
if ('tags' in req.body) input.tags = Array.isArray(req.body.tags) ? req.body.tags : []
|
|
if ('category_id' in req.body) {
|
|
const cat = await resolveCategoryId(req.body)
|
|
if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
|
|
input.categoryId = cat.value
|
|
}
|
|
|
|
const page = await wiki.update(req.params.slug, input)
|
|
await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } })
|
|
return res.json(page)
|
|
} catch (err) {
|
|
log.error('updateWiki', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function publishWiki(req, res) {
|
|
try {
|
|
const page = await wiki.setPublished(req.params.slug, Boolean(req.body.published))
|
|
if (!page) return res.status(404).json({ message: 'Not found' })
|
|
await activity.log({
|
|
req,
|
|
action: 'wiki.publish',
|
|
detail: { slug: req.params.slug, published: Boolean(req.body.published) },
|
|
})
|
|
return res.json(page)
|
|
} catch (err) {
|
|
log.error('publishWiki', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function deleteWiki(req, res) {
|
|
try {
|
|
await wiki.remove(req.params.slug)
|
|
await activity.log({ req, action: 'wiki.delete', detail: { slug: req.params.slug } })
|
|
return res.json({ slug: req.params.slug })
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Wiki revisions ─────────────────────────────────────────────────────
|
|
async function listWikiRevisions(req, res) {
|
|
try {
|
|
const revisions = await wiki.listRevisions(req.params.slug)
|
|
if (revisions == null) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(revisions)
|
|
} catch (err) {
|
|
log.error('listWikiRevisions', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function getWikiRevision(req, res) {
|
|
try {
|
|
const rev = await wiki.getRevision(req.params.slug, Number(req.params.id))
|
|
if (!rev) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(rev)
|
|
} catch (err) {
|
|
log.error('getWikiRevision', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function restoreWikiRevision(req, res) {
|
|
try {
|
|
const page = await wiki.restoreRevision(req.params.slug, Number(req.params.id), req.user.id)
|
|
if (!page) return res.status(404).json({ message: 'Not found' })
|
|
await activity.log({
|
|
req,
|
|
action: 'wiki.revision.restore',
|
|
detail: { slug: req.params.slug, revision: Number(req.params.id) },
|
|
})
|
|
return res.json(page)
|
|
} catch (err) {
|
|
log.error('restoreWikiRevision', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Wiki tags ──────────────────────────────────────────────────────────
|
|
async function listWikiTags(req, res) {
|
|
try {
|
|
return res.json(await wiki.listTags())
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Wiki categories ────────────────────────────────────────────────────
|
|
async function listWikiCategories(req, res) {
|
|
try {
|
|
return res.json(await wiki.listCategories())
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function createWikiCategory(req, res) {
|
|
try {
|
|
if (await wiki.getCategoryBySlug(req.body.slug)) {
|
|
return res.status(409).json({ message: 'A category with that slug already exists' })
|
|
}
|
|
const category = await wiki.createCategory({
|
|
slug: req.body.slug,
|
|
title: req.body.title,
|
|
description: req.body.description || null,
|
|
sortOrder: Number(req.body.sort_order) || 0,
|
|
})
|
|
await activity.log({ req, action: 'wiki.category.create', detail: { slug: category.slug } })
|
|
return res.status(201).json(category)
|
|
} catch (err) {
|
|
log.error('createWikiCategory', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function updateWikiCategory(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const existing = await wiki.getCategoryById(id)
|
|
if (!existing) return res.status(404).json({ message: 'Not found' })
|
|
|
|
const input = {}
|
|
if ('title' in req.body) input.title = req.body.title
|
|
if ('description' in req.body) input.description = req.body.description || null
|
|
if ('sort_order' in req.body) input.sortOrder = Number(req.body.sort_order) || 0
|
|
if ('slug' in req.body && req.body.slug !== existing.slug) {
|
|
const clash = await wiki.getCategoryBySlug(req.body.slug)
|
|
if (clash) return res.status(409).json({ message: 'A category with that slug already exists' })
|
|
input.slug = req.body.slug
|
|
}
|
|
|
|
const category = await wiki.updateCategory(id, input)
|
|
await activity.log({ req, action: 'wiki.category.update', detail: { id } })
|
|
return res.json(category)
|
|
} catch (err) {
|
|
log.error('updateWikiCategory', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function deleteWikiCategory(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const existing = await wiki.getCategoryById(id)
|
|
if (!existing) return res.status(404).json({ message: 'Not found' })
|
|
await wiki.removeCategory(id) // pages in it become uncategorized
|
|
await activity.log({ req, action: 'wiki.category.delete', detail: { id } })
|
|
return res.json({ id })
|
|
} catch (err) {
|
|
log.error('deleteWikiCategory', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
async function getSettings(req, res) {
|
|
try {
|
|
return res.json(await settings.getAll())
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function updateSettings(req, res) {
|
|
const updates = req.body
|
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
|
return res.status(400).json({ message: 'Expected an object of key/value settings' })
|
|
}
|
|
// Enum-constrained keys are validated here (the store itself is schemaless).
|
|
if (
|
|
settings.REGISTRATION_KEY in updates &&
|
|
!settings.REGISTRATION_MODES.includes(updates[settings.REGISTRATION_KEY])
|
|
) {
|
|
return res.status(400).json({ message: 'Invalid player_registration value' })
|
|
}
|
|
if (
|
|
settings.GAME_SIGNUP_KEY in updates &&
|
|
!settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
|
|
) {
|
|
return res.status(400).json({ message: 'Invalid game_account_signup value' })
|
|
}
|
|
// App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
|
|
// boolean or those two strings and normalize, reject anything else.
|
|
if (settings.MOBILE_APP_LINKS_KEY in updates) {
|
|
const v = updates[settings.MOBILE_APP_LINKS_KEY]
|
|
if (v !== true && v !== false && v !== 'true' && v !== 'false') {
|
|
return res.status(400).json({ message: 'Invalid mobile_app_links_enabled value' })
|
|
}
|
|
updates[settings.MOBILE_APP_LINKS_KEY] = String(v === true || v === 'true')
|
|
}
|
|
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
|
|
// against the same allowlist as post/wiki bodies so a stored value is safe (the
|
|
// client re-sanitizes on render as defense in depth).
|
|
if (typeof updates.homepage_teaser === 'string') {
|
|
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
|
|
}
|
|
// theme_visual is JSON whose values become CSS custom properties, so every
|
|
// one has to come from the closed sets in config/themePresets.js. The read
|
|
// path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently
|
|
// storing a value that will never apply is a bad admin experience — reject it
|
|
// with the offending field named instead. Accepts an object or the stringified
|
|
// form, and stores it stringified either way, since settings.value is TEXT.
|
|
if ('theme_visual' in updates) {
|
|
const raw = updates.theme_visual
|
|
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
|
if (typeof raw === 'string' && parsed === null) {
|
|
return res.status(400).json({ message: 'theme_visual must be a JSON object' })
|
|
}
|
|
const check = validateThemeVisual(parsed)
|
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
|
updates.theme_visual = JSON.stringify(parsed)
|
|
}
|
|
// brand_assets holds the only settings values written straight into HTML the
|
|
// browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
|
|
// the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
|
|
// dropped rather than stored as null, keeping "a field is absent" the single
|
|
// meaning of "falls back to BRAND_* env".
|
|
if ('brand_assets' in updates) {
|
|
const raw = updates.brand_assets
|
|
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
|
if (typeof raw === 'string' && parsed === null) {
|
|
return res.status(400).json({ message: 'brand_assets must be a JSON object' })
|
|
}
|
|
const check = validateBrandAssets(parsed)
|
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
|
updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
|
|
}
|
|
// The three nav rows are JSON too, and without this they would reach
|
|
// settingsDb.set as objects and be stored as the string "[object Object]".
|
|
// Shape only — whether a key names a route the nav actually declares is the
|
|
// client's question, and utils/navOverrides.js says why. Resolved on the way
|
|
// in so the stored row carries no dead fields, and so `hidden` can never land
|
|
// on the nav editor's own row.
|
|
for (const key of NAV_KEYS) {
|
|
if (!(key in updates)) continue
|
|
const raw = updates[key]
|
|
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
|
if (typeof raw === 'string' && parsed === null) {
|
|
return res.status(400).json({ message: `${key} must be a JSON object` })
|
|
}
|
|
const check = validateNavOverrides(parsed, key)
|
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
|
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
|
}
|
|
try {
|
|
await settings.setMany(updates, req.user.id)
|
|
// The HTML shell is templated from brand_assets and theme_visual, and is
|
|
// cached per process (utils/htmlShell.js) — a write that can change it has
|
|
// to say so, or the favicon an admin just uploaded appears only after the
|
|
// cache's TTL.
|
|
if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
|
|
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
|
return res.json(await settings.getAll())
|
|
} catch (err) {
|
|
log.error('updateSettings', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Brand assets ──────────────────────────────────────────────────────
|
|
//
|
|
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
|
|
// itself is never widened (§9: "no second upload path with weaker validation") —
|
|
// these only ever tighten it:
|
|
//
|
|
// • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
|
|
// fact that the stored extension comes from that map is exactly what makes
|
|
// the upload path safe (§4.10). Every browser this app supports takes a PNG
|
|
// icon. Small cap: a favicon is a handful of KB.
|
|
// • logo — a header mark next to the site title, not a page image.
|
|
// • hero — a full-bleed background, so it keeps the shared ceiling.
|
|
//
|
|
// The cap is checked after multer has written the file rather than by a second
|
|
// multer instance: one upload config, one allowlist, and the oversized file is
|
|
// unlinked before we answer.
|
|
const ASSET_RULES = {
|
|
logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
|
|
hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
|
|
favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
|
|
}
|
|
|
|
const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)
|
|
|
|
// Best-effort cleanup of a file we have decided not to keep. A failure here is
|
|
// a stray file in /uploads, not something the caller can act on.
|
|
async function discardUpload(file) {
|
|
try {
|
|
await fs.promises.unlink(file.path)
|
|
} catch (err) {
|
|
log.error('discardUpload', err)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
|
|
* brand_assets row at it in the same call.
|
|
*
|
|
* One call rather than "upload, then PUT the settings row": a half-completed
|
|
* save would otherwise leave a file in /uploads that nothing references, and the
|
|
* per-slot rules above need the slot at upload time anyway. Admin-only, matching
|
|
* the gate on the settings it writes — POST /admin/uploads is reachable by
|
|
* editors, who have no business changing the site's identity.
|
|
*/
|
|
async function uploadBrandAsset(req, res) {
|
|
const { slot } = req.params
|
|
const rules = ASSET_RULES[slot]
|
|
if (!rules) {
|
|
if (req.file) await discardUpload(req.file)
|
|
return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
|
|
}
|
|
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
|
if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
|
|
await discardUpload(req.file)
|
|
return res.status(400).json({ message: `${rules.label} must be a PNG image` })
|
|
}
|
|
if (req.file.size > rules.maxBytes) {
|
|
await discardUpload(req.file)
|
|
return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
|
|
}
|
|
|
|
const url = `/uploads/${req.file.filename}`
|
|
try {
|
|
// Read-modify-write the row: uploading a logo must not clear a hero the
|
|
// admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
|
|
// with one bad slot does not block setting another.
|
|
const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
|
|
const next = { ...current, [slot]: url }
|
|
await settings.set('brand_assets', JSON.stringify(next), req.user.id)
|
|
htmlShell.invalidate()
|
|
await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
|
|
return res.status(201).json({ url, brand_assets: next })
|
|
} catch (err) {
|
|
log.error('uploadBrandAsset', err)
|
|
// The row is the point of the call; a stored file nothing points at is
|
|
// litter, so it goes back out with the error.
|
|
await discardUpload(req.file)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Delete one settings row — the "reset to defaults" primitive.
|
|
//
|
|
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
|
|
// hardcoded NAV arrays; the *absence* of the row is what selects them
|
|
// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not
|
|
// store a copy of the defaults, or the next change to a default would not reach
|
|
// an instance that had ever pressed reset.
|
|
//
|
|
// The key allowlist is the point of the route: an unrestricted DELETE would let
|
|
// a stray request drop site_mode or the uo-link config, where absence means
|
|
// something else entirely. Deleting a key that is not set succeeds — reset is
|
|
// idempotent and the UI should not have to know whether a row exists.
|
|
async function deleteSetting(req, res) {
|
|
const { key } = req.params
|
|
if (!settings.DELETABLE_KEYS.includes(key)) {
|
|
return res.status(400).json({ message: 'Setting is not resettable' })
|
|
}
|
|
try {
|
|
await settings.remove(key)
|
|
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
|
|
await activity.log({ req, action: 'settings.reset', detail: { key } })
|
|
return res.json({ message: 'Setting reset to default' })
|
|
} catch (err) {
|
|
log.error('deleteSetting', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Activity log ──────────────────────────────────────────────────────
|
|
async function listActivity(req, res) {
|
|
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
|
const offset = Number(req.query.offset) || 0
|
|
try {
|
|
return res.json(await activity.list({ limit, offset }))
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── User management ───────────────────────────────────────────────────
|
|
async function listUsers(req, res) {
|
|
try {
|
|
return res.json(await users.list())
|
|
} catch (err) {
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
|
//
|
|
// Lived in usersShard.controller.js until PR 4, purely because the detail page it
|
|
// backs is mostly shard panels — MODULE_SYSTEM.md §1.9 called that out as core
|
|
// semantics that ended up in the UO controller by proximity. Reading a user is
|
|
// core's, and it stays here when the shard panels leave.
|
|
async function getUser(req, res) {
|
|
try {
|
|
const user = await users.getById(Number(req.params.id))
|
|
if (!user) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(user)
|
|
} catch (err) {
|
|
log.error('getUser', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function createUser(req, res) {
|
|
try {
|
|
if (await users.getRawByUsername(req.body.username)) {
|
|
return res.status(409).json({ message: 'Username already taken' })
|
|
}
|
|
const user = await users.createUser({
|
|
username: req.body.username,
|
|
password: req.body.password,
|
|
role: req.body.role || 'admin',
|
|
email: req.body.email || null,
|
|
status: req.body.status || 'active',
|
|
})
|
|
await activity.log({
|
|
req,
|
|
action: 'user.create',
|
|
detail: { id: user.id, username: user.username, role: user.role },
|
|
})
|
|
return res.status(201).json(user)
|
|
} catch (err) {
|
|
log.error('createUser', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function updateUser(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const target = await users.getById(id)
|
|
if (!target) return res.status(404).json({ message: 'Not found' })
|
|
|
|
// If the username is changing, make sure no other user already has it —
|
|
// return 409 rather than letting the DB unique constraint throw a 500.
|
|
if (req.body.username && req.body.username !== target.username) {
|
|
const clash = await users.getRawByUsername(req.body.username)
|
|
if (clash) return res.status(409).json({ message: 'Username already taken' })
|
|
}
|
|
|
|
// Don't let the last admin demote themselves out of admin access.
|
|
if (target.role === 'admin' && req.body.role && req.body.role !== 'admin') {
|
|
if ((await users.countAdmins()) <= 1) {
|
|
return res.status(400).json({ message: 'Cannot demote the last admin' })
|
|
}
|
|
}
|
|
const user = await users.update(id, {
|
|
username: req.body.username,
|
|
password: req.body.password,
|
|
role: req.body.role,
|
|
email: req.body.email,
|
|
status: req.body.status,
|
|
})
|
|
await activity.log({ req, action: 'user.update', detail: { id } })
|
|
// Distinct audit trail for the security-sensitive fields (role & status),
|
|
// so a promotion/ban is greppable beyond the generic user.update entry.
|
|
if (req.body.role && req.body.role !== target.role) {
|
|
await activity.log({
|
|
req,
|
|
action: 'admin.user.role_change',
|
|
detail: { id, from: target.role, to: req.body.role },
|
|
})
|
|
}
|
|
if (req.body.status && req.body.status !== target.status) {
|
|
await activity.log({
|
|
req,
|
|
action: 'admin.user.status_change',
|
|
detail: { id, from: target.status, to: req.body.status },
|
|
})
|
|
}
|
|
return res.json(user)
|
|
} catch (err) {
|
|
log.error('updateUser', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function deleteUser(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
if (id === req.user.id) {
|
|
return res.status(400).json({ message: 'You cannot delete your own account' })
|
|
}
|
|
const target = await users.getById(id)
|
|
if (!target) return res.status(404).json({ message: 'Not found' })
|
|
if (target.role === 'admin' && (await users.countAdmins()) <= 1) {
|
|
return res.status(400).json({ message: 'Cannot delete the last admin' })
|
|
}
|
|
await users.remove(id)
|
|
await activity.log({ req, action: 'user.delete', detail: { id } })
|
|
return res.json({ id })
|
|
} catch (err) {
|
|
log.error('deleteUser', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// ── Admin: a user's trusted devices & MFA (admin only) ─────────────────────
|
|
// Staff-facing view/revocation of another user's trusted devices, plus an MFA
|
|
// reset for a locked-out user. All actions are audit-logged with the acting admin
|
|
// (via activity.log's req) and the target user id.
|
|
function toAdminTrustedDevice(r) {
|
|
return {
|
|
id: r.id,
|
|
platform: r.platform,
|
|
deviceName: r.device_name || null,
|
|
userAgent: r.user_agent || null,
|
|
createdAt: r.created_at,
|
|
lastUsedAt: r.last_used_at || r.created_at,
|
|
expiresAt: r.expires_at,
|
|
}
|
|
}
|
|
|
|
async function listUserTrustedDevices(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const target = await users.getById(id)
|
|
if (!target) return res.status(404).json({ message: 'Not found' })
|
|
const rows = await trustedDevices.listActiveForUser(id)
|
|
return res.json(rows.map(toAdminTrustedDevice))
|
|
} catch (err) {
|
|
log.error('listUserTrustedDevices', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function revokeUserTrustedDevice(req, res) {
|
|
const id = Number(req.params.id)
|
|
const deviceId = Number(req.params.deviceId)
|
|
try {
|
|
const target = await users.getById(id)
|
|
if (!target) return res.status(404).json({ message: 'Not found' })
|
|
const n = await trustedDevices.revokeByIdForUser(deviceId, id)
|
|
if (n) {
|
|
await activity.log({ req, action: 'admin.trusted_device.revoke', detail: { userId: id, deviceId } })
|
|
log.info('admin revoked trusted device', { adminId: req.user.id, userId: id, deviceId })
|
|
}
|
|
return res.json({ revoked: n > 0 })
|
|
} catch (err) {
|
|
log.error('revokeUserTrustedDevice', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
async function revokeAllUserTrustedDevices(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const target = await users.getById(id)
|
|
if (!target) return res.status(404).json({ message: 'Not found' })
|
|
const n = await trustedDevices.revokeAllForUser(id)
|
|
await activity.log({ req, action: 'admin.trusted_device.revoke_all', detail: { userId: id, count: n } })
|
|
log.info('admin revoked all trusted devices', { adminId: req.user.id, userId: id, count: n })
|
|
return res.json({ revoked: n })
|
|
} catch (err) {
|
|
log.error('revokeAllUserTrustedDevices', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Reset a locked-out user's MFA: turn TOTP off, drop every trusted device, and
|
|
// clear their recovery codes. Lets an admin recover a user who lost their
|
|
// authenticator; the user can then sign in with their password alone and re-enroll.
|
|
async function resetUserMfa(req, res) {
|
|
const id = Number(req.params.id)
|
|
try {
|
|
const target = await users.getById(id)
|
|
if (!target) return res.status(404).json({ message: 'Not found' })
|
|
await users.disableTotp(id)
|
|
await trustedDevices.revokeAllForUser(id)
|
|
await recoveryCodes.clearForUser(id)
|
|
await activity.log({ req, action: 'admin.user.totp.reset', detail: { userId: id } })
|
|
log.info('admin reset user MFA', { adminId: req.user.id, userId: id })
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
log.error('resetUserMfa', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
dashboard,
|
|
setSiteMode,
|
|
listPosts,
|
|
getPost,
|
|
createPost,
|
|
updatePost,
|
|
publishPost,
|
|
deletePost,
|
|
getAnnounceStatus,
|
|
retryAnnounceLeg,
|
|
uploadImage,
|
|
uploadFile,
|
|
listWiki,
|
|
getWiki,
|
|
createWiki,
|
|
updateWiki,
|
|
publishWiki,
|
|
deleteWiki,
|
|
listWikiRevisions,
|
|
getWikiRevision,
|
|
restoreWikiRevision,
|
|
listWikiTags,
|
|
listWikiCategories,
|
|
createWikiCategory,
|
|
updateWikiCategory,
|
|
deleteWikiCategory,
|
|
getSettings,
|
|
updateSettings,
|
|
deleteSetting,
|
|
uploadBrandAsset,
|
|
ASSET_RULES,
|
|
listActivity,
|
|
listUsers,
|
|
getUser,
|
|
createUser,
|
|
updateUser,
|
|
deleteUser,
|
|
listUserTrustedDevices,
|
|
revokeUserTrustedDevice,
|
|
revokeAllUserTrustedDevices,
|
|
resetUserMfa,
|
|
}
|