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 , a , 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, }