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:
20
server/src/model/activity/activity.db.js
Normal file
20
server/src/model/activity/activity.db.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
async function insert({ userId = null, action, detail = null, ip = null }) {
|
||||
const res = await query(
|
||||
'INSERT INTO activity_log (user_id, action, detail, ip) VALUES (?, ?, ?, ?)',
|
||||
[userId, action, detail, ip],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function list({ limit = 50, offset = 0 } = {}) {
|
||||
return query(
|
||||
'SELECT a.id, a.user_id, u.username, a.action, a.detail, a.ip, a.created_at ' +
|
||||
'FROM activity_log a LEFT JOIN users u ON u.id = a.user_id ' +
|
||||
'ORDER BY a.id DESC LIMIT ? OFFSET ?',
|
||||
[limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { insert, list }
|
||||
25
server/src/model/activity/activity.model.js
Normal file
25
server/src/model/activity/activity.model.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const activityDb = require('./activity.db')
|
||||
|
||||
const logger = require('../../utils/logger')('activity')
|
||||
|
||||
/**
|
||||
* Record an admin action. `detail` may be an object (stored as JSON). Never throws
|
||||
* into the request path — logging must not break the action it records.
|
||||
*/
|
||||
async function log({ req, userId, action, detail }) {
|
||||
try {
|
||||
const resolvedUserId = userId ?? (req && req.user ? req.user.id : null)
|
||||
const ip = req ? req.ip : null
|
||||
const detailStr =
|
||||
detail == null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
|
||||
await activityDb.insert({ userId: resolvedUserId, action, detail: detailStr, ip })
|
||||
} catch (err) {
|
||||
logger.error(`failed to record action "${action}"`, { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function list(opts) {
|
||||
return activityDb.list(opts)
|
||||
}
|
||||
|
||||
module.exports = { log, list }
|
||||
84
server/src/model/posts/posts.db.js
Normal file
84
server/src/model/posts/posts.db.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, category, title, slug, excerpt, body, image_url, published, author_id, created_at, updated_at, published_at'
|
||||
|
||||
// Published posts for a category, newest first — public feed.
|
||||
async function listPublished(category) {
|
||||
return query(
|
||||
`SELECT ${COLS} FROM posts WHERE category = ? AND published = 1 ` +
|
||||
'ORDER BY COALESCE(published_at, created_at) DESC, id DESC',
|
||||
[category],
|
||||
)
|
||||
}
|
||||
|
||||
// All posts for a category (admin), newest first.
|
||||
async function listAll(category) {
|
||||
if (category) {
|
||||
return query(`SELECT ${COLS} FROM posts WHERE category = ? ORDER BY id DESC`, [category])
|
||||
}
|
||||
return query(`SELECT ${COLS} FROM posts ORDER BY id DESC`)
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM posts WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findPublished(category, id, slug) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLS} FROM posts WHERE category = ? AND published = 1 AND (id = ? OR slug = ?) LIMIT 1`,
|
||||
[category, id, slug],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function insert(post) {
|
||||
const res = await query(
|
||||
'INSERT INTO posts (category, title, slug, excerpt, body, image_url, published, author_id, published_at) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
post.category,
|
||||
post.title,
|
||||
post.slug || null,
|
||||
post.excerpt || null,
|
||||
post.body || null,
|
||||
post.image_url || null,
|
||||
post.published ? 1 : 0,
|
||||
post.author_id || null,
|
||||
post.published ? new Date() : null,
|
||||
],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function update(id, fields) {
|
||||
const cols = []
|
||||
const params = []
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
cols.push(`${key} = ?`)
|
||||
params.push(val)
|
||||
}
|
||||
if (cols.length === 0) return
|
||||
params.push(id)
|
||||
await query(`UPDATE posts SET ${cols.join(', ')} WHERE id = ?`, params)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
return query('DELETE FROM posts WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
async function countByCategory() {
|
||||
return query('SELECT category, COUNT(*) AS c FROM posts GROUP BY category')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listPublished,
|
||||
listAll,
|
||||
findById,
|
||||
findPublished,
|
||||
insert,
|
||||
update,
|
||||
remove,
|
||||
countByCategory,
|
||||
}
|
||||
89
server/src/model/posts/posts.model.js
Normal file
89
server/src/model/posts/posts.model.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const postsDb = require('./posts.db')
|
||||
|
||||
// URL category (kebab) <-> DB enum value.
|
||||
const CATEGORY_MAP = {
|
||||
news: 'news',
|
||||
'five-on-friday': 'five_on_friday',
|
||||
newsletter: 'newsletter',
|
||||
screenshots: 'screenshot',
|
||||
}
|
||||
const URL_CATEGORIES = Object.keys(CATEGORY_MAP)
|
||||
const DB_CATEGORIES = Object.values(CATEGORY_MAP)
|
||||
|
||||
function toDbCategory(urlCategory) {
|
||||
return CATEGORY_MAP[urlCategory] || null
|
||||
}
|
||||
|
||||
function isValidUrlCategory(urlCategory) {
|
||||
return Boolean(CATEGORY_MAP[urlCategory])
|
||||
}
|
||||
|
||||
function isValidDbCategory(dbCategory) {
|
||||
return DB_CATEGORIES.includes(dbCategory)
|
||||
}
|
||||
|
||||
async function listPublished(urlCategory) {
|
||||
return postsDb.listPublished(toDbCategory(urlCategory))
|
||||
}
|
||||
|
||||
async function getPublished(urlCategory, idOrSlug) {
|
||||
const id = Number.isInteger(Number(idOrSlug)) ? Number(idOrSlug) : -1
|
||||
return postsDb.findPublished(toDbCategory(urlCategory), id, String(idOrSlug))
|
||||
}
|
||||
|
||||
async function listAll(dbCategory) {
|
||||
return postsDb.listAll(dbCategory || null)
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return postsDb.findById(id)
|
||||
}
|
||||
|
||||
async function create(post) {
|
||||
const id = await postsDb.insert(post)
|
||||
return postsDb.findById(id)
|
||||
}
|
||||
|
||||
async function update(id, fields) {
|
||||
await postsDb.update(id, fields)
|
||||
return postsDb.findById(id)
|
||||
}
|
||||
|
||||
async function setPublished(id, published) {
|
||||
const current = await postsDb.findById(id)
|
||||
if (!current) return null
|
||||
const fields = { published: published ? 1 : 0 }
|
||||
// Stamp published_at the first time a post goes live.
|
||||
if (published && !current.published_at) fields.published_at = new Date()
|
||||
await postsDb.update(id, fields)
|
||||
return postsDb.findById(id)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
return postsDb.remove(id)
|
||||
}
|
||||
|
||||
async function counts() {
|
||||
const rows = await postsDb.countByCategory()
|
||||
return rows.reduce((acc, row) => {
|
||||
acc[row.category] = Number(row.c)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
URL_CATEGORIES,
|
||||
DB_CATEGORIES,
|
||||
toDbCategory,
|
||||
isValidUrlCategory,
|
||||
isValidDbCategory,
|
||||
listPublished,
|
||||
getPublished,
|
||||
listAll,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
setPublished,
|
||||
remove,
|
||||
counts,
|
||||
}
|
||||
25
server/src/model/settings/settings.db.js
Normal file
25
server/src/model/settings/settings.db.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
async function getAll() {
|
||||
return query('SELECT `key`, value, updated_at FROM settings ORDER BY `key`')
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
const rows = await query('SELECT value FROM settings WHERE `key` = ? LIMIT 1', [key])
|
||||
return rows[0] ? rows[0].value : null
|
||||
}
|
||||
|
||||
async function set(key, value, updatedBy = null) {
|
||||
await query(
|
||||
'INSERT INTO settings (`key`, value, updated_by) VALUES (?, ?, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by)',
|
||||
[key, value, updatedBy],
|
||||
)
|
||||
}
|
||||
|
||||
// Insert a default only if the key does not already exist.
|
||||
async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault }
|
||||
43
server/src/model/settings/settings.model.js
Normal file
43
server/src/model/settings/settings.model.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const settingsDb = require('./settings.db')
|
||||
|
||||
// Keys safe to expose on the public site.
|
||||
const PUBLIC_KEYS = [
|
||||
'site_mode',
|
||||
'maintenance_message',
|
||||
'status_message',
|
||||
'homepage_teaser',
|
||||
'contact_email',
|
||||
'site_title',
|
||||
]
|
||||
|
||||
async function get(key) {
|
||||
return settingsDb.get(key)
|
||||
}
|
||||
|
||||
async function set(key, value, updatedBy = null) {
|
||||
return settingsDb.set(key, value, updatedBy)
|
||||
}
|
||||
|
||||
async function setMany(obj, updatedBy = null) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
await settingsDb.set(key, value, updatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
async function getAll() {
|
||||
const rows = await settingsDb.getAll()
|
||||
return rows.reduce((acc, row) => {
|
||||
acc[row.key] = row.value
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
async function getPublic() {
|
||||
const all = await getAll()
|
||||
return PUBLIC_KEYS.reduce((acc, key) => {
|
||||
if (all[key] !== undefined) acc[key] = all[key]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
module.exports = { get, set, setMany, getAll, getPublic, PUBLIC_KEYS }
|
||||
67
server/src/model/users/users.db.js
Normal file
67
server/src/model/users/users.db.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const PUBLIC_COLS = 'id, username, role, created_at, last_login_at'
|
||||
|
||||
async function insertUser({ username, passwordHash, role = 'admin' }) {
|
||||
const res = await query(
|
||||
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
|
||||
[username, passwordHash, role],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findByUsername(username) {
|
||||
const rows = await query('SELECT * FROM users WHERE username = ? LIMIT 1', [username])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query('SELECT * FROM users WHERE id = ? LIMIT 1', [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
|
||||
}
|
||||
|
||||
async function updateUser(id, fields) {
|
||||
const cols = []
|
||||
const params = []
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
cols.push(`${key} = ?`)
|
||||
params.push(val)
|
||||
}
|
||||
if (cols.length === 0) return
|
||||
params.push(id)
|
||||
await query(`UPDATE users SET ${cols.join(', ')} WHERE id = ?`, params)
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
return query('DELETE FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
async function countUsers() {
|
||||
const rows = await query('SELECT COUNT(*) AS c FROM users')
|
||||
return Number(rows[0].c)
|
||||
}
|
||||
|
||||
async function countAdmins() {
|
||||
const rows = await query("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
|
||||
return Number(rows[0].c)
|
||||
}
|
||||
|
||||
async function touchLastLogin(id) {
|
||||
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insertUser,
|
||||
findByUsername,
|
||||
findById,
|
||||
listUsers,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
countUsers,
|
||||
countAdmins,
|
||||
touchLastLogin,
|
||||
}
|
||||
73
server/src/model/users/users.model.js
Normal file
73
server/src/model/users/users.model.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const bcrypt = require('bcryptjs')
|
||||
const usersDb = require('./users.db')
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
// Strip the password hash before sending a user anywhere.
|
||||
function sanitize(user) {
|
||||
if (!user) return null
|
||||
const { password_hash, ...safe } = user
|
||||
return safe
|
||||
}
|
||||
|
||||
async function createUser({ username, password, role = 'admin' }) {
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||
const id = await usersDb.insertUser({ username, passwordHash, role })
|
||||
return sanitize(await usersDb.findById(id))
|
||||
}
|
||||
|
||||
// Returns the raw row (incl. hash) — used by login only.
|
||||
async function getRawByUsername(username) {
|
||||
return usersDb.findByUsername(username)
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return sanitize(await usersDb.findById(id))
|
||||
}
|
||||
|
||||
async function validatePassword(user, password) {
|
||||
if (!user || !user.password_hash) return false
|
||||
return bcrypt.compare(password, user.password_hash)
|
||||
}
|
||||
|
||||
async function list() {
|
||||
return usersDb.listUsers()
|
||||
}
|
||||
|
||||
async function update(id, { username, password, role }) {
|
||||
const fields = {}
|
||||
if (username !== undefined) fields.username = username
|
||||
if (role !== undefined) fields.role = role
|
||||
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||
await usersDb.updateUser(id, fields)
|
||||
return getById(id)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
return usersDb.deleteUser(id)
|
||||
}
|
||||
|
||||
async function count() {
|
||||
return usersDb.countUsers()
|
||||
}
|
||||
|
||||
async function countAdmins() {
|
||||
return usersDb.countAdmins()
|
||||
}
|
||||
|
||||
async function recordLogin(id) {
|
||||
return usersDb.touchLastLogin(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createUser,
|
||||
getRawByUsername,
|
||||
getById,
|
||||
validatePassword,
|
||||
list,
|
||||
update,
|
||||
remove,
|
||||
count,
|
||||
countAdmins,
|
||||
recordLogin,
|
||||
}
|
||||
46
server/src/model/wiki/wiki.db.js
Normal file
46
server/src/model/wiki/wiki.db.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
async function listSummaries() {
|
||||
return query('SELECT slug, title, updated_at FROM wiki_pages ORDER BY title ASC')
|
||||
}
|
||||
|
||||
async function findBySlug(slug) {
|
||||
const rows = await query('SELECT * FROM wiki_pages WHERE slug = ? LIMIT 1', [slug])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function insert({ slug, title, body, updatedBy = null }) {
|
||||
const res = await query(
|
||||
'INSERT INTO wiki_pages (slug, title, body, updated_by) VALUES (?, ?, ?, ?)',
|
||||
[slug, title, body || null, updatedBy],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function updateBySlug(slug, { title, body, updatedBy = null }) {
|
||||
await query(
|
||||
'UPDATE wiki_pages SET title = ?, body = ?, updated_by = ? WHERE slug = ?',
|
||||
[title, body || null, updatedBy, slug],
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteBySlug(slug) {
|
||||
return query('DELETE FROM wiki_pages WHERE slug = ?', [slug])
|
||||
}
|
||||
|
||||
async function seedDefault(slug, title, body) {
|
||||
await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [
|
||||
slug,
|
||||
title,
|
||||
body || null,
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listSummaries,
|
||||
findBySlug,
|
||||
insert,
|
||||
updateBySlug,
|
||||
deleteBySlug,
|
||||
seedDefault,
|
||||
}
|
||||
25
server/src/model/wiki/wiki.model.js
Normal file
25
server/src/model/wiki/wiki.model.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const wikiDb = require('./wiki.db')
|
||||
|
||||
async function list() {
|
||||
return wikiDb.listSummaries()
|
||||
}
|
||||
|
||||
async function getBySlug(slug) {
|
||||
return wikiDb.findBySlug(slug)
|
||||
}
|
||||
|
||||
async function create({ slug, title, body, updatedBy }) {
|
||||
await wikiDb.insert({ slug, title, body, updatedBy })
|
||||
return wikiDb.findBySlug(slug)
|
||||
}
|
||||
|
||||
async function update(slug, { title, body, updatedBy }) {
|
||||
await wikiDb.updateBySlug(slug, { title, body, updatedBy })
|
||||
return wikiDb.findBySlug(slug)
|
||||
}
|
||||
|
||||
async function remove(slug) {
|
||||
return wikiDb.deleteBySlug(slug)
|
||||
}
|
||||
|
||||
module.exports = { list, getBySlug, create, update, remove }
|
||||
Reference in New Issue
Block a user