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:
91
server/src/app.js
Normal file
91
server/src/app.js
Normal 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
|
||||
7
server/src/middleware/noindex.js
Normal file
7
server/src/middleware/noindex.js
Normal 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
|
||||
35
server/src/middleware/rateLimit.js
Normal file
35
server/src/middleware/rateLimit.js
Normal 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 }
|
||||
31
server/src/middleware/siteMode.js
Normal file
31
server/src/middleware/siteMode.js
Normal 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
|
||||
12
server/src/middleware/validate.js
Normal file
12
server/src/middleware/validate.js
Normal 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
|
||||
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 }
|
||||
9
server/src/router/api.router.js
Normal file
9
server/src/router/api.router.js
Normal 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
|
||||
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
|
||||
47
server/src/router/v1/auth/auth.controller.js
Normal file
47
server/src/router/v1/auth/auth.controller.js
Normal 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 }
|
||||
22
server/src/router/v1/auth/auth.routes.js
Normal file
22
server/src/router/v1/auth/auth.routes.js
Normal 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
|
||||
90
server/src/router/v1/public/public.controller.js
Normal file
90
server/src/router/v1/public/public.controller.js
Normal 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,
|
||||
}
|
||||
30
server/src/router/v1/public/public.routes.js
Normal file
30
server/src/router/v1/public/public.routes.js
Normal 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
|
||||
13
server/src/router/v1/v1.router.js
Normal file
13
server/src/router/v1/v1.router.js
Normal 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
73
server/src/server.js
Normal 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
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