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

91
server/src/app.js Normal file
View File

@@ -0,0 +1,91 @@
const express = require('express')
const path = require('path')
const fs = require('fs')
const cors = require('cors')
const helmet = require('helmet')
const morgan = require('morgan')
const cookieParser = require('cookie-parser')
require('dotenv').config()
const apiRouter = require('./router/api.router')
const createLogger = require('./utils/logger')
const httpLog = createLogger('http')
const errLog = createLogger('error')
const app = express()
// Behind Pangolin: trust the first proxy so req.secure (for the cookie flag),
// req.ip (activity log / rate limiting) reflect the X-Forwarded-* headers.
app.set('trust proxy', 1)
// Security headers. CSP is left off here and will be tuned for the React SPA in
// the frontend phase; the rest of helmet's protections stay enabled.
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
}),
)
// CORS only when a separate client origin is configured (local Vite dev). In
// production the SPA is same-origin, so no CORS is needed.
if (process.env.CLIENT_ORIGIN) {
app.use(cors({ origin: process.env.CLIENT_ORIGIN, credentials: true }))
}
// Access logs: real client IP (via trust proxy), the authenticated admin (if any),
// method, URL, status, response time, and size. Bodies/credentials are never logged.
morgan.token('user', (req) => (req.user && req.user.username) || '-')
const accessFormat =
':remote-addr :user :method :url :status :response-time ms - :res[content-length] bytes'
app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim()) } }))
app.use(express.json({ limit: '2mb' }))
app.use(cookieParser())
// ── Paths ─────────────────────────────────────────────────────────────
const SERVER_ROOT = path.join(__dirname, '..')
const REPO_ROOT = path.join(SERVER_ROOT, '..')
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads')
const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist')
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
// Uploaded images — always served, even during maintenance.
app.use('/uploads', express.static(UPLOAD_DIR))
// ── API ───────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => res.json({ status: 'ok' }))
app.use('/api', apiRouter)
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
// ── Client SPA ────────────────────────────────────────────────────────
// Serve the built React app if present; otherwise show a placeholder so the
// server is usable API-only before the frontend phase.
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
app.use(express.static(CLIENT_DIST))
app.get('*', (req, res) => res.sendFile(path.join(CLIENT_DIST, 'index.html')))
} else {
app.get('*', (req, res) =>
res
.type('html')
.send(
'<h1>UOMysticmoon API</h1><p>The web client has not been built yet. ' +
'The API is available under <code>/api/v1</code>.</p>',
),
)
}
// ── Error handler ─────────────────────────────────────────────────────
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = err.status || (err.name === 'MulterError' ? 400 : 500)
// Log the stack for server faults; client (4xx) errors stay terse.
errLog.error(
`${req.method} ${req.originalUrl} -> ${status} ${err.message}`,
status >= 500 ? { stack: err.stack } : undefined,
)
res.status(status).json({ message: err.message || 'Internal Server Error' })
})
module.exports = app

View File

@@ -0,0 +1,7 @@
// Keep admin endpoints out of search indexes.
function noindex(req, res, next) {
res.set('X-Robots-Tag', 'noindex, nofollow')
next()
}
module.exports = noindex

View File

@@ -0,0 +1,35 @@
const rateLimit = require('express-rate-limit')
const log = require('../utils/logger')('ratelimit')
function makeLimiter({ windowMs, max, label, message }) {
return rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
message: { message },
handler: (req, res, next, options) => {
log.warn(`${label} rate limit exceeded`, { ip: req.ip, path: req.originalUrl })
res.status(options.statusCode).json(options.message)
},
})
}
// Brute-force protection on login.
const loginLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'login',
message: 'Too many login attempts. Please try again later.',
})
// Throttle the public contact form.
const contactLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 5,
label: 'contact',
message: 'Too many messages sent. Please try again later.',
})
module.exports = { loginLimiter, contactLimiter }

View File

@@ -0,0 +1,31 @@
const { getUserFromRequest } = require('../utils/auth')
const settings = require('../model/settings/settings.model')
const log = require('../utils/logger')('sitemode')
/**
* Gate for public *content* routes (posts, wiki). When the site is in maintenance
* mode, respond 503 with the maintenance message — UNLESS the request carries a
* valid admin token (admin "preview live"). Settings/status/contact are not gated,
* so the client can always fetch the maintenance message to render the page.
*/
async function siteMode(req, res, next) {
try {
const mode = await settings.get('site_mode')
if (mode !== 'maintenance') return next()
// Authenticated admins bypass the gate so they can preview the live site.
if (getUserFromRequest(req)) return next()
log.debug('blocked request (maintenance mode)', { path: req.originalUrl, ip: req.ip })
const message = await settings.get('maintenance_message')
return res.status(503).json({
mode: 'maintenance',
message: message || 'The site is currently under maintenance.',
})
} catch (err) {
return next(err)
}
}
module.exports = siteMode

View File

@@ -0,0 +1,12 @@
const { validationResult } = require('express-validator')
// Collect express-validator results and 400 on failure.
function validate(req, res, next) {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ message: 'Validation failed', errors: errors.array() })
}
next()
}
module.exports = validate

View 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 }

View 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 }

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

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

View 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 }

View 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 }

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

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

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

View 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 }

View File

@@ -0,0 +1,9 @@
const express = require('express')
const apiRouter = express.Router()
const v1Router = require('./v1/v1.router')
apiRouter.use('/v1', v1Router)
module.exports = apiRouter

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

View File

@@ -0,0 +1,47 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const { signToken, setAuthCookie, clearAuthCookie } = require('../../../utils/auth')
const log = require('../../../utils/logger')('auth')
async function login(req, res) {
const { username, password } = req.body
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
if (!ok) {
log.warn('login failed', { username, ip: req.ip })
return res.status(401).json({ message: 'Incorrect username or password.' })
}
await users.recordLogin(user.id)
const token = signToken(user)
setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip })
return res.json({
user: { id: user.id, username: user.username, role: user.role },
})
} catch (err) {
log.error('login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function logout(req, res) {
clearAuthCookie(req, res)
return res.json({ message: 'Logged out.' })
}
async function me(req, res) {
try {
const user = await users.getById(req.user.id)
if (!user) return res.status(401).json({ message: 'Unauthorized' })
return res.json({ user })
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { login, logout, me }

View File

@@ -0,0 +1,22 @@
const express = require('express')
const { body } = require('express-validator')
const { login, logout, me } = require('./auth.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { loginLimiter } = require('../../../middleware/rateLimit')
const validate = require('../../../middleware/validate')
const authRouter = express.Router()
authRouter.post(
'/login',
loginLimiter,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
validate,
login,
)
authRouter.post('/logout', logout)
authRouter.get('/me', isLoggedIn, me)
module.exports = authRouter

View File

@@ -0,0 +1,90 @@
const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model')
const mailer = require('../../../utils/mailer')
const log = require('../../../utils/logger')('public')
async function getSettings(req, res) {
try {
return res.json(await settings.getPublic())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getStatus(req, res) {
try {
return res.json({
mode: (await settings.get('site_mode')) || 'live',
status_message: (await settings.get('status_message')) || '',
})
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getPosts(req, res) {
const { category } = req.params
if (!posts.isValidUrlCategory(category)) {
return res.status(404).json({ message: 'Unknown category' })
}
try {
return res.json(await posts.listPublished(category))
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getPost(req, res) {
const { category, idOrSlug } = req.params
if (!posts.isValidUrlCategory(category)) {
return res.status(404).json({ message: 'Unknown category' })
}
try {
const post = await posts.getPublished(category, idOrSlug)
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 getWikiList(req, res) {
try {
return res.json(await wiki.list())
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getWikiPage(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 contact(req, res) {
const { name, email, message } = req.body
try {
const result = await mailer.sendContactMessage({ name, email, message })
return res.json(result)
} catch (err) {
log.error('contact send failed', err)
return res.status(502).json({ message: 'Could not send message right now.' })
}
}
module.exports = {
getSettings,
getStatus,
getPosts,
getPost,
getWikiList,
getWikiPage,
contact,
}

View File

@@ -0,0 +1,30 @@
const express = require('express')
const { body } = require('express-validator')
const ctrl = require('./public.controller')
const siteMode = require('../../../middleware/siteMode')
const validate = require('../../../middleware/validate')
const { contactLimiter } = require('../../../middleware/rateLimit')
const publicRouter = express.Router()
// Always available (so the client can render the maintenance page + contact).
publicRouter.get('/settings', ctrl.getSettings)
publicRouter.get('/status', ctrl.getStatus)
publicRouter.post(
'/contact',
contactLimiter,
body('message').isString().trim().notEmpty().isLength({ max: 5000 }),
body('email').optional({ values: 'falsy' }).isEmail(),
body('name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
ctrl.contact,
)
// Content — gated by site mode (admins with a valid token bypass for preview).
publicRouter.get('/posts/:category', siteMode, ctrl.getPosts)
publicRouter.get('/posts/:category/:idOrSlug', siteMode, ctrl.getPost)
publicRouter.get('/wiki', siteMode, ctrl.getWikiList)
publicRouter.get('/wiki/:slug', siteMode, ctrl.getWikiPage)
module.exports = publicRouter

View File

@@ -0,0 +1,13 @@
const express = require('express')
const v1Router = express.Router()
const authRouter = require('./auth/auth.routes')
const publicRouter = require('./public/public.routes')
const adminRouter = require('./admin/admin.routes')
v1Router.use('/auth', authRouter)
v1Router.use('/public', publicRouter)
v1Router.use('/admin', adminRouter)
module.exports = v1Router

73
server/src/server.js Normal file
View File

@@ -0,0 +1,73 @@
require('dotenv').config()
const http = require('http')
const app = require('./app')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
const mailer = require('./utils/mailer')
const createLogger = require('./utils/logger')
const pkg = require('../package.json')
const log = createLogger('server')
const PORT = Number(process.env.PORT) || 3000
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
async function start() {
log.info(`starting UOMysticmoon server v${pkg.version}`, {
node: process.version,
env: process.env.NODE_ENV || 'development',
logLevel: process.env.LOG_LEVEL || 'info',
logFile: createLogger.logFilePath || 'disabled (console only)',
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`,
cookieSecure: process.env.COOKIE_SECURE || 'auto',
smtp: mailer.isConfigured() ? 'configured' : 'not configured (mailto fallback)',
})
log.info('ensuring database schema...')
await ensureSchema()
log.info('seeding defaults...')
await seedDefaults()
await createInitialAdminFromEnv()
const mode = await settings.get('site_mode')
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
const server = http.createServer(app)
server.listen(PORT, HOST, () => {
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
})
setupShutdown(server)
}
function setupShutdown(server) {
let closing = false
const shutdown = async (signal) => {
if (closing) return
closing = true
log.warn(`${signal} received — shutting down gracefully`)
server.close(() => log.info('http server closed'))
try {
await close()
log.info('database pool closed')
} catch (err) {
log.error('error closing database pool', err)
}
await createLogger.close() // flush the log file
process.exit(0)
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
process.exit(1)
})
}
start().catch((err) => {
log.error('failed to start server', err)
process.exit(1)
})

96
server/src/utils/auth.js Normal file
View File

@@ -0,0 +1,96 @@
const jwt = require('jsonwebtoken')
require('dotenv').config()
const log = require('./logger')('auth')
const JWT_SECRET = process.env.JWT_SECRET
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
if (!JWT_SECRET) {
log.warn('JWT_SECRET is not set — set it in .env before going to production')
}
function signToken(user) {
const payload = { id: user.id, username: user.username, role: user.role }
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN })
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (err) {
return null
}
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
if (!m) return 24 * 60 * 60 * 1000
const n = Number(m[1])
const unit = { d: 86400000, h: 3600000, m: 60000, s: 1000 }[m[2]]
return n * unit
}
/**
* Decide the cookie Secure flag. COOKIE_SECURE=auto (default) uses req.secure,
* which is true behind Pangolin (HTTPS, X-Forwarded-Proto) and false over plain
* HTTP on the LAN IP — so login works in both. Requires app.set('trust proxy').
*/
function cookieSecure(req) {
const mode = (process.env.COOKIE_SECURE || 'auto').toLowerCase()
if (mode === 'true') return true
if (mode === 'false') return false
return Boolean(req.secure)
}
function cookieOptions(req) {
return {
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure(req),
path: '/',
}
}
function setAuthCookie(req, res, token) {
res.cookie(COOKIE_NAME, token, { ...cookieOptions(req), maxAge: cookieMaxAge() })
}
function clearAuthCookie(req, res) {
res.clearCookie(COOKIE_NAME, cookieOptions(req))
}
// Extract a token from the cookie or an Authorization: Bearer header.
function extractToken(req) {
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME]
const header = req.headers.authorization
if (header && header.startsWith('Bearer ')) return header.substring(7)
return null
}
// Returns the decoded user or null without rejecting the request.
function getUserFromRequest(req) {
const token = extractToken(req)
if (!token) return null
return verifyToken(token)
}
// Gate middleware for protected (admin) routes.
function isLoggedIn(req, res, next) {
const user = getUserFromRequest(req)
if (!user) return res.status(401).json({ message: 'Unauthorized' })
req.user = user
return next()
}
module.exports = {
COOKIE_NAME,
signToken,
verifyToken,
setAuthCookie,
clearAuthCookie,
getUserFromRequest,
isLoggedIn,
}

78
server/src/utils/db.js Normal file
View File

@@ -0,0 +1,78 @@
const fs = require('fs')
const path = require('path')
const mariadb = require('mariadb')
require('dotenv').config()
const log = require('./logger')('db')
const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'uomysticmoon',
connectionLimit: 5,
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
insertIdAsNumber: true,
bigIntAsNumber: true,
decimalAsNumber: true,
})
/**
* Run a parameterized query and release the connection.
* @param {string} sql
* @param {Array} [params]
*/
async function query(sql, params) {
const conn = await pool.getConnection()
try {
return await conn.query(sql, params)
} finally {
conn.release()
}
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
* Create tables if they do not exist. Idempotent. Retries while the DB is still
* coming up (important under docker-compose even with a healthcheck).
*/
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const conn = await pool.getConnection()
try {
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
// Strip full-line comments first, then split — so a leading comment block
// doesn't get glued onto (and discard) the statement that follows it.
const statements = sql
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0)
for (const statement of statements) {
await conn.query(statement)
}
log.info('schema ensured')
return
} finally {
conn.release()
}
} catch (err) {
if (attempt === retries) throw err
log.warn(`database not ready, retrying (attempt ${attempt}/${retries})`, {
code: err.code || err.message,
})
await new Promise((r) => setTimeout(r, delayMs))
}
}
}
async function close() {
await pool.end()
}
module.exports = { pool, query, ensureSchema, close }

View File

@@ -0,0 +1,94 @@
// Dual-transport logger: writes to the console AND to a log file.
// Levels: error | warn | info | debug.
// LOG_LEVEL console verbosity (default info)
// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk)
// LOG_TO_FILE enable file logging (default true)
// LOG_DIR log directory (default <server>/logs)
// LOG_FILE log file name (default app.log)
const fs = require('fs')
const path = require('path')
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug
// Color only on an interactive TTY — never in files or Docker logs.
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null
const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' }
const RESET = '\x1b[0m'
// ── File transport ────────────────────────────────────────────────────
const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false'
let fileStream = null
let logFilePath = null
if (fileEnabled) {
try {
const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs')
fs.mkdirSync(dir, { recursive: true })
logFilePath = path.join(dir, process.env.LOG_FILE || 'app.log')
fileStream = fs.createWriteStream(logFilePath, { flags: 'a' })
fileStream.on('error', (err) => {
process.stderr.write(`[logger] file logging disabled: ${err.message}\n`)
fileStream = null
})
} catch (err) {
process.stderr.write(`[logger] could not open log file: ${err.message}\n`)
fileStream = null
}
}
function fmt(meta) {
if (meta == null) return ''
if (typeof meta === 'string') return meta
if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack })
try {
return JSON.stringify(meta)
} catch {
return String(meta)
}
}
function emit(level, tag, msg, meta) {
const levelNum = LEVELS[level]
if (levelNum === undefined) return
const ts = new Date().toISOString()
const lvl = level.toUpperCase().padEnd(5)
const label = tag ? ` [${tag}]` : ''
const metaStr = meta === undefined ? '' : ` ${fmt(meta)}`
const plain = `${ts} ${lvl}${label} ${msg}${metaStr}`
// Console transport
if (levelNum <= consoleThreshold) {
const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout
stream.write(`${line}\n`)
}
// File transport (plain text, no color)
if (fileStream && levelNum <= fileThreshold) {
fileStream.write(`${plain}\n`)
}
}
function createLogger(tag) {
return {
error: (msg, meta) => emit('error', tag, msg, meta),
warn: (msg, meta) => emit('warn', tag, msg, meta),
info: (msg, meta) => emit('info', tag, msg, meta),
debug: (msg, meta) => emit('debug', tag, msg, meta),
}
}
// Flush and close the file stream (called on graceful shutdown).
createLogger.close = () =>
new Promise((resolve) => {
if (fileStream) fileStream.end(resolve)
else resolve()
})
createLogger.emit = emit
createLogger.logFilePath = logFilePath
module.exports = createLogger

View File

@@ -0,0 +1,41 @@
const nodemailer = require('nodemailer')
require('dotenv').config()
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, CONTACT_TO } = process.env
function isConfigured() {
return Boolean(SMTP_HOST && CONTACT_TO)
}
let transporter = null
function getTransporter() {
if (!transporter) {
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT) || 587,
secure: Number(SMTP_PORT) === 465,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
})
}
return transporter
}
/**
* Send a contact message. If SMTP is not configured, signals the caller to fall
* back to a mailto: link instead of throwing. Credentials come from env only.
*/
async function sendContactMessage({ name, email, message }) {
if (!isConfigured()) {
return { sent: false, fallback: 'mailto', email: CONTACT_TO || null }
}
await getTransporter().sendMail({
from: SMTP_USER || CONTACT_TO,
to: CONTACT_TO,
replyTo: email,
subject: `UOMysticmoon contact from ${name || 'a visitor'}`,
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
})
return { sent: true }
}
module.exports = { isConfigured, sendContactMessage }