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:
96
server/src/utils/auth.js
Normal file
96
server/src/utils/auth.js
Normal 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
78
server/src/utils/db.js
Normal 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 }
|
||||
94
server/src/utils/logger.js
Normal file
94
server/src/utils/logger.js
Normal 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
|
||||
41
server/src/utils/mailer.js
Normal file
41
server/src/utils/mailer.js
Normal 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 }
|
||||
Reference in New Issue
Block a user