Initial commit: UOMysticmoon backend (Express + MariaDB + JWT)

- Layered API (router -> controller -> model -> db), serverlinkr pattern
- Public / auth / admin route groups; posts, wiki, settings, users, activity models
- JWT httpOnly-cookie auth (Secure auto-detected: LAN HTTP + Pangolin HTTPS)
- Site LIVE/MAINTENANCE mode with admin preview bypass
- Dual file+console logging (info/warn/error/debug) + HTTP access logs
- Docker Compose (app + MariaDB), schema.sql + seed, .env.example
- Verified end-to-end against MariaDB (27/27 smoke checks)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 20:58:32 -05:00
commit eef79e2403
41 changed files with 4195 additions and 0 deletions

View File

@@ -0,0 +1,357 @@
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 log = require('../../../utils/logger')('admin')
// ── 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 } })
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 } })
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 updated = await posts.setPublished(id, Boolean(req.body.published))
if (!updated) return res.status(404).json({ message: 'Not found' })
await activity.log({
req,
action: 'post.publish',
detail: { id, published: Boolean(req.body.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 })
}
// ── Wiki ──────────────────────────────────────────────────────────────
async function listWiki(req, res) {
try {
return res.json(await wiki.list())
} 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' })
}
}
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 page = await wiki.create({
slug: req.body.slug,
title: req.body.title,
body: req.body.body || null,
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 page = await wiki.update(req.params.slug, {
title: req.body.title,
body: req.body.body || null,
updatedBy: req.user.id,
})
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 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' })
}
}
// ── 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' })
// 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,
listWiki,
getWiki,
createWiki,
updateWiki,
deleteWiki,
getSettings,
updateSettings,
listActivity,
listUsers,
createUser,
updateUser,
deleteUser,
}

View File

@@ -0,0 +1,113 @@
const express = require('express')
const path = require('path')
const fs = require('fs')
const multer = require('multer')
const { body, param } = require('express-validator')
const ctrl = require('./admin.controller')
const { isLoggedIn } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const adminRouter = express.Router()
// Every admin route requires auth and is kept out of search indexes.
adminRouter.use(noindex, isLoggedIn)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase()
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`)
},
})
const upload = multer({
storage,
limits: { fileSize: 8 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (/^image\/(png|jpe?g|gif|webp|avif)$/.test(file.mimetype)) cb(null, true)
else cb(new Error('Only image uploads are allowed'))
},
})
// ── Dashboard & site mode ─────────────────────────────────────────────
adminRouter.get('/dashboard', ctrl.dashboard)
adminRouter.put(
'/site-mode',
body('mode').isIn(['live', 'maintenance']),
validate,
ctrl.setSiteMode,
)
// ── Posts (news / five-on-friday / newsletter / screenshots) ──────────
adminRouter.get('/posts', ctrl.listPosts)
adminRouter.post(
'/posts',
body('category').isString().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
validate,
ctrl.createPost,
)
adminRouter.post('/posts/upload', upload.single('image'), ctrl.uploadImage)
adminRouter.get('/posts/:id', param('id').isInt(), validate, ctrl.getPost)
adminRouter.put('/posts/:id', param('id').isInt(), validate, ctrl.updatePost)
adminRouter.patch(
'/posts/:id/publish',
param('id').isInt(),
body('published').isBoolean(),
validate,
ctrl.publishPost,
)
adminRouter.delete('/posts/:id', param('id').isInt(), validate, ctrl.deletePost)
// ── Wiki ──────────────────────────────────────────────────────────────
adminRouter.get('/wiki', ctrl.listWiki)
adminRouter.post(
'/wiki',
body('slug').matches(/^[a-z0-9-]+$/),
body('title').isString().trim().notEmpty(),
validate,
ctrl.createWiki,
)
adminRouter.get('/wiki/:slug', ctrl.getWiki)
adminRouter.put(
'/wiki/:slug',
body('title').isString().trim().notEmpty(),
validate,
ctrl.updateWiki,
)
adminRouter.delete('/wiki/:slug', ctrl.deleteWiki)
// ── Settings ──────────────────────────────────────────────────────────
adminRouter.get('/settings', ctrl.getSettings)
adminRouter.put('/settings', ctrl.updateSettings)
// ── Activity log ──────────────────────────────────────────────────────
adminRouter.get('/activity', ctrl.listActivity)
// ── User management ───────────────────────────────────────────────────
adminRouter.get('/users', ctrl.listUsers)
adminRouter.post(
'/users',
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor']),
validate,
ctrl.createUser,
)
adminRouter.put(
'/users/:id',
param('id').isInt(),
body('password').optional().isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor']),
validate,
ctrl.updateUser,
)
adminRouter.delete('/users/:id', param('id').isInt(), validate, ctrl.deleteUser)
module.exports = adminRouter