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:
357
server/src/router/v1/admin/admin.controller.js
Normal file
357
server/src/router/v1/admin/admin.controller.js
Normal 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,
|
||||
}
|
||||
113
server/src/router/v1/admin/admin.routes.js
Normal file
113
server/src/router/v1/admin/admin.routes.js
Normal 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
|
||||
Reference in New Issue
Block a user