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 botInternalClient = require('../../../utils/botInternalClient') const log = require('../../../utils/logger')('admin') // Public base URL for links back to the site — same fallback pattern as // sso.controller.js's redirect_uri builder. function appBaseUrl(req) { const configured = process.env.APP_BASE_URL if (configured) return configured.replace(/\/+$/, '') return `${req.protocol}://${req.get('host')}` } // Fire-and-forget: announce a news post to Discord the moment it actually // transitions from unpublished to published — not on every save or on a // no-op re-publish of an already-live post. Never throws (botInternalClient // itself never rejects); a bot outage must never break publishing a post. function announceIfNewlyPublished(req, post, wasPublished) { if (!post || post.category !== 'news' || !post.published || wasPublished) return const base = appBaseUrl(req) // image_url is stored relative (e.g. "/uploads/xyz.png") — Discord embeds // require an absolute URL. const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null botInternalClient .announce({ title: post.title, excerpt: post.excerpt, url: `${base}/site/news`, imageUrl, }) .catch(() => {}) } // ── 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 } }) announceIfNewlyPublished(req, created, 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 } }) announceIfNewlyPublished(req, updated, Boolean(current.published)) 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) }, }) announceIfNewlyPublished(req, updated, Boolean(current.published)) 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 { await posts.remove(id) await activity.log({ req, action: 'post.delete', detail: { id } }) return res.json({ id }) } catch (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' }) } try { await settings.setMany(updates, req.user.id) 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' }) } } // ── 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' }) } } 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', }) await activity.log({ req, action: 'user.create', detail: { id: user.id, username: user.username } }) 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, }) await activity.log({ req, action: 'user.update', detail: { id } }) 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' }) } } module.exports = { dashboard, setSiteMode, listPosts, getPost, createPost, updatePost, publishPost, deletePost, uploadImage, uploadFile, listWiki, getWiki, createWiki, updateWiki, publishWiki, deleteWiki, listWikiRevisions, getWikiRevision, restoreWikiRevision, listWikiTags, listWikiCategories, createWikiCategory, updateWikiCategory, deleteWikiCategory, getSettings, updateSettings, listActivity, listUsers, createUser, updateUser, deleteUser, }