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

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
**/node_modules
**/dist
.git
.env
*.env
!.env.example
_reference
server/uploads
uploads
server/logs
logs
*.log
.DS_Store
Thumbs.db

45
.env.example Normal file
View File

@@ -0,0 +1,45 @@
# ─── UOMysticmoon — root environment (used by docker-compose) ───
# Copy to .env and fill in. NEVER commit the real .env.
# App
NODE_ENV=production
PORT=3000
UPLOAD_DIR=/app/uploads
# Logging — written to BOTH the console and a log file.
LOG_LEVEL=info # console verbosity: error | warn | info | debug
FILE_LOG_LEVEL=debug # file verbosity (keep a full record on disk)
LOG_TO_FILE=true # set false for console-only
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
LOG_FILE=app.log
# Database (the values here are shared by the `db` and `app` containers)
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=change-me-db-password
DB_ROOT_PASSWORD=change-me-root-password
# Auth
JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRES_IN=1d
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
COOKIE_SECURE=auto
COOKIE_NAME=uomm_token
# First admin bootstrap — created only if no users exist yet.
# Set, run once, then you can blank these out.
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the
# client to fall back to a mailto: link instead.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
# CORS — only needed for local dev when the Vite dev server is a different origin.
CLIENT_ORIGIN=http://localhost:5173

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# dependencies
node_modules/
client/node_modules/
server/node_modules/
# build output
client/dist/
# env / secrets
.env
*.env
!.env.example
# runtime data
server/uploads/
uploads/
server/logs/
logs/
# reference material (extracted from the provided archives)
_reference/
# logs / os
*.log
npm-debug.log*
.DS_Store
Thumbs.db
# editor / tooling local settings
.claude/settings.local.json
.vscode/
.idea/

330
BACKEND_DESIGN.md Normal file
View File

@@ -0,0 +1,330 @@
# UOMysticmoon Website — Backend Design
> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
> This document is the contract the later phases build against.
Public contact email: **UOMysticmoon@gmail.com**
---
## 1. Stack & top-level decisions
| Concern | Decision | Rationale |
|---|---|---|
| Runtime | Node.js + Express | serverlinkr pattern |
| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) |
| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel |
| Frontend | React + Vite, same repo, served by Express in prod | spec |
| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr |
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
**Adapting serverlinkr → this project**
- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them.
- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth".
- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**.
---
## 2. Folder structure
Skeleton from the spec, with a small number of justified additions marked **(+)**.
```
server/
.env.example
package.json
db/
schema.sql (+) DDL, also auto-run by the MariaDB container
seed.js (+) seed wiki pages, default settings, first admin
src/
server.js bootstrap: ensure schema, then listen on 0.0.0.0
app.js express app + middleware wiring
router/
api.router.js mounts /v1
v1/
v1.router.js mounts /auth /public /admin
auth/ auth.routes.js + auth.controller.js
public/ public.routes.js + public.controller.js
admin/ admin.routes.js + admin.controller.js
model/
users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
wiki/ wiki.model.js + wiki.db.js
settings/ settings.model.js + settings.db.js
activity/ activity.model.js + activity.db.js (+) admin activity log
middleware/ (+)
siteMode.js LIVE/MAINTENANCE gate for public content
noindex.js X-Robots-Tag: noindex,nofollow on admin
rateLimit.js login limiter
validate.js express-validator error handler
utils/
auth.js JWT sign/verify, isLoggedIn middleware
db.js MariaDB pool + ensureSchema()
mailer.js (+) nodemailer; mailto fallback if SMTP unset
client/ built in Phase 2/3 (React + Vite)
Dockerfile
docker-compose.yml
.env.example
.gitignore
```
**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode
gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four
listed models/two utils. They're isolated in `middleware/` + one `activity` model +
`utils/mailer.js`, and the spec explicitly says the layout is "expandable."
---
## 3. Database schema (MariaDB)
`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as
`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`.
### users
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| username | VARCHAR(32) UNIQUE NOT NULL | |
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| last_login_at | DATETIME NULL | shown in user management |
### posts — one table, four categories
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(220) NULL | optional clean URL |
| excerpt | VARCHAR(400) NULL | list teaser |
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere |
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| published_at | DATETIME NULL | set when first published; list order |
Index: `(category, published, published_at DESC)`.
### wiki_pages
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` |
| title | VARCHAR(200) NOT NULL | |
| body | MEDIUMTEXT NULL | markdown/HTML |
| updated_by | INT NULL FK→users(id) | |
| created_at / updated_at | DATETIME | |
Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`.
### settings — key/value, expandable
| col | type | notes |
|---|---|---|
| `key` | VARCHAR(64) PK | |
| value | TEXT NULL | |
| updated_by | INT NULL FK→users(id) | |
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | |
Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
### activity_log — append-only
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NULL FK→users(id) | |
| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` |
| detail | TEXT NULL | JSON string of what changed |
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
---
## 4. API contract
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
accepts `Authorization: Bearer` for API testing).
### /auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose |
|---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/logout` | cookie | — | clear cookie |
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
admins are created under `/admin/users`.
### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
| GET | `/status` | status message + current mode |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
Public content GETs pass through the **siteMode** gate (§5).
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
| Method | Path | Purpose |
|---|---|---|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
| GET | `/posts?category=` | all posts incl. unpublished |
| POST | `/posts` | create |
| GET | `/posts/:id` | one |
| PUT | `/posts/:id` | edit |
| DELETE | `/posts/:id` | delete |
| PATCH | `/posts/:id/publish` | `{published}` toggle (sets `published_at`) |
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
Every admin write logs to `activity_log`.
---
## 5. Site mode (LIVE / MAINTENANCE)
State in `settings.site_mode` (`live`|`maintenance`), default **maintenance**.
`middleware/siteMode.js`, applied only to **public content** routes:
- `live` → pass through.
- `maintenance` → respond **503** with `{mode:"maintenance", message}` **unless** the request
carries a valid admin cookie (admin preview). This hides content server-side, not just in
the UI.
Always reachable regardless of mode: static assets / SPA shell, `/api/v1/auth/*`, all
`/api/v1/admin/*`. So admin login + panel + the maintenance "coming soon" page always load.
**Client behavior (Phase 3):** reads `GET /public/settings`; if `maintenance` and not an
admin previewing, render the polished dark coming-soon page (message + contact email).
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change +
who"; `activity_log` provides the history feed.
---
## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
---
## 7. Email
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
---
## 7.5 Logging & observability
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
`/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL`
(default `debug`, so the file keeps a complete record while the console stays readable).
Toggle with `LOG_TO_FILE`. The stream is flushed on graceful shutdown.
- **HTTP access logs** via morgan piped into the logger: real client IP (`trust proxy`),
authenticated admin username, method, URL, status, response time, size.
- **Captured events**: startup config banner, schema/seed steps, login success/failure,
rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
logged. `unhandledRejection`/`uncaughtException` are caught and logged.
## 8. Deployment
**docker-compose.yml** — two services on a private network:
- `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume
`dbdata:/var/lib/mysql`, mounts `schema.sql` into `/docker-entrypoint-initdb.d`, healthcheck.
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- Volumes: `dbdata`, `uploads`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
**.env.example** (committed; real `.env` ignored):
```
NODE_ENV=production
PORT=3000
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=
DB_ROOT_PASSWORD=
JWT_SECRET=
JWT_EXPIRES_IN=1d
COOKIE_SECURE=true
COOKIE_NAME=uomm_token
ADMIN_USERNAME=
ADMIN_PASSWORD=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
CLIENT_ORIGIN=http://localhost:5173
```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
---
## 9. Dependencies (server)
`express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser,
express-rate-limit, express-validator, multer, nodemailer` · dev: `nodemon`.
Removed vs serverlinkr: `mongoose, mongodb, connect-mongo, express-session, passport,
passport-local`.
---
## 10. Spec coverage
| Spec requirement | Covered by |
|---|---|
| Public pages (`/`, `/site/*`, `/wiki/*`) | `/public/*` API + Phase-3 SPA routes; content from `posts`/`wiki`/`settings` |
| News / 5-on-Friday / Newsletter / Screenshots | `posts` table, `category` column; admin CRUD + publish |
| Wiki 8 categories, editable later | `wiki_pages` seeded with 8 slugs; admin CRUD |
| Status page | `settings.status_message` + mode via `/public/status` |
| Admin dashboard (mode, last change, who) | `/admin/dashboard` + settings stamps + activity log |
| Site mode toggle | `PUT /admin/site-mode` + `siteMode` middleware |
| Admin activity log | `activity_log` + `/admin/activity` |
| Admin user management | `/admin/users` CRUD |
| Site settings editing | `/admin/settings` |
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
| Maintenance page, admin always in, static always loads, admin preview | §5 |
| SMTP via env, mailto fallback | §7 |
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
| Design tokens / hero | reused from existing `assets/css/mysticmoon.css` + hero PNG in Phase 2/3 |
| Expandable | key/value settings, role enum, modular routers/models |
```

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:20-alpine
WORKDIR /app
# Install server deps first for better layer caching (production only).
COPY server/package*.json server/
RUN npm install --prefix server --omit=dev
# Copy the rest of the repo. .dockerignore keeps node_modules/.env/_reference out,
# so the server deps installed above are preserved.
COPY . .
# Build the client if it is present (added in the frontend phase). Until then the
# server runs API-only and serves a placeholder at /.
RUN if [ -f client/package.json ]; then \
npm install --prefix client && npm run build --prefix client; \
else \
echo "No client/ present — building server-only image"; \
fi
# Persistent uploads + logs live on mounted volumes.
RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs
USER node
EXPOSE 3000
CMD ["npm", "start", "--prefix", "server"]

88
README.md Normal file
View File

@@ -0,0 +1,88 @@
# UOMysticmoon Website
Public site, wiki, and protected admin panel for the UOMysticmoon private Ultima Online
shard. Built on the `serverlinkr` layered pattern: **Express + MariaDB + JWT** backend and a
**React + Vite** frontend in the same repo, deployed with **Docker Compose** behind a
**Pangolin** reverse proxy.
> Build order: **(1) backend** (this phase) → (2) frontend design (Claude Design) →
> (3) frontend coding. See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the full design.
## Layout
```
server/ Express API (router → controller → model → db), MariaDB schema + seed
client/ React + Vite SPA (added in the frontend phase)
Dockerfile, docker-compose.yml, .env.example
```
## Quick start (local dev)
```bash
# 1. Start a MariaDB (or use your own and set DB_* in server/.env)
docker compose up -d db
# 2. Configure + install
cp server/.env.example server/.env # edit DB_*, JWT_SECRET, ADMIN_USERNAME/PASSWORD
npm run install-server
# 3. Run the API (creates tables, seeds defaults + first admin on boot)
npm run server # http://localhost:3000 (API at /api/v1)
```
`GET /api/health``{ "status": "ok" }` confirms it's up.
## Deploy (Docker Compose)
```bash
cp .env.example .env # fill in DB creds, JWT_SECRET, admin, SMTP
docker compose up -d --build # app on 0.0.0.0:3000, MariaDB on the internal network
```
Point Pangolin at the `app` container on port 3000. The auth cookie auto-detects HTTPS, so
the admin panel works both via the LAN IP (HTTP) and through the proxy (HTTPS). The full app
image build requires `client/` (frontend phase); until then the server runs API-only.
## Key endpoints
| Group | Base | Auth |
|---|---|---|
| Auth | `/api/v1/auth` (`login`, `logout`, `me`) | cookie |
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `wiki`, `contact`) | none |
| Admin | `/api/v1/admin` (dashboard, site-mode, posts, wiki, settings, activity, users) | cookie (admin) |
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the complete contract.
## Security notes
JWT in an httpOnly cookie · bcrypt hashing · login rate limiting · admin routes `noindex` ·
first admin seeded from env (no hardcoded credentials) · `.env` is git-ignored. SMTP is
optional — the contact form falls back to a `mailto:` link when SMTP is not configured.
## Logging
Every log line goes to **both the console and a log file**, timestamped and leveled
(`error` / `warn` / `info` / `debug`):
```
2026-06-26T18:55:01.123Z INFO [server] listening on http://0.0.0.0:3000 ...
2026-06-26T18:55:09.880Z INFO [http] 192.168.1.40 admin POST /api/v1/auth/login 200 12 ms - 48 bytes
2026-06-26T18:55:14.402Z WARN [auth] login failed {"username":"root","ip":"192.168.1.40"}
2026-06-26T18:55:20.110Z ERROR [error] GET /api/v1/public/wiki -> 500 ... {"stack":"..."}
```
What's captured: startup config banner, schema/seed steps, **HTTP access logs** (real client
IP via `trust proxy`, the authenticated admin, method/URL/status/time/size), login
success/failure, rate-limit hits, site-mode changes, all errors with stack traces, and
graceful shutdown. Passwords and request bodies are never logged.
| Env | Default | Meaning |
|---|---|---|
| `LOG_LEVEL` | `info` | console verbosity |
| `FILE_LOG_LEVEL` | `debug` | file verbosity (keeps a full record) |
| `LOG_TO_FILE` | `true` | set `false` for console-only |
| `LOG_DIR` | `<server>/logs` (`/app/logs` in Docker) | log directory |
| `LOG_FILE` | `app.log` | log file name |
In Docker the log file is bind-mounted to `./logs/app.log` on the host; `docker compose logs -f app`
also shows the console stream.

44
docker-compose.yml Normal file
View File

@@ -0,0 +1,44 @@
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- dbdata:/var/lib/mysql
- ./server/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
# No host port published by default — only the app needs the DB, over the
# private compose network. Uncomment to inspect from the host:
# ports:
# - "3306:3306"
app:
build: .
restart: unless-stopped
env_file: .env
environment:
DB_HOST: db
UPLOAD_DIR: /app/uploads
LOG_DIR: /app/logs
depends_on:
db:
condition: service_healthy
volumes:
- uploads:/app/uploads
# Bind-mount logs to the host so app.log is directly readable at ./logs/
- ./logs:/app/logs
# Binds 0.0.0.0 (no 127.0.0.1 prefix) so Pangolin can reach the container.
ports:
- "3000:3000"
volumes:
dbdata:
uploads:

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "uomysticmoon-website",
"version": "1.0.0",
"description": "UOMysticmoon — public site, wiki, and admin panel for a private Ultima Online shard",
"private": true,
"scripts": {
"install-server": "npm install --prefix server",
"install-client": "npm install --prefix client",
"install-all": "npm run install-server && npm run install-client",
"server": "npm run dev --prefix server",
"client": "npm run dev --prefix client",
"seed": "npm run seed --prefix server",
"build": "npm run build --prefix client",
"start": "npm start --prefix server"
},
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
"author": "whitlocktech",
"license": "ISC"
}

36
server/.env.example Normal file
View File

@@ -0,0 +1,36 @@
# ─── UOMysticmoon server — local dev environment ───
# Copy to server/.env for running `npm run dev` outside Docker.
# (In Docker, the root .env / docker-compose provides these instead.)
NODE_ENV=development
PORT=3000
# Logging — written to BOTH the console and a log file (default <server>/logs/app.log).
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
FILE_LOG_LEVEL=debug # file verbosity
LOG_TO_FILE=true # set false for console-only
# LOG_DIR= # defaults to server/logs
# LOG_FILE=app.log
# Point at a local or Dockerized MariaDB
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=change-me-db-password
JWT_SECRET=dev-only-change-me
JWT_EXPIRES_IN=1d
COOKIE_SECURE=auto
COOKIE_NAME=uomm_token
# Created on first boot if the users table is empty
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
CLIENT_ORIGIN=http://localhost:5173

59
server/db/schema.sql Normal file
View File

@@ -0,0 +1,59 @@
-- UOMysticmoon database schema (MariaDB)
-- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a
-- fresh volume, and idempotently by ensureSchema() on every server boot.
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(32) NOT NULL UNIQUE,
password_hash VARCHAR(72) NOT NULL,
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL,
title VARCHAR(200) NOT NULL,
slug VARCHAR(220) NULL,
excerpt VARCHAR(400) NULL,
body MEDIUMTEXT NULL,
image_url VARCHAR(500) NULL,
published TINYINT(1) NOT NULL DEFAULT 0,
author_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
published_at DATETIME NULL,
CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_posts_feed (category, published, published_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_pages (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS settings (
`key` VARCHAR(64) PRIMARY KEY,
value TEXT NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_settings_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS activity_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
action VARCHAR(64) NOT NULL,
detail TEXT NULL,
ip VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_activity_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

72
server/db/seed.js Normal file
View File

@@ -0,0 +1,72 @@
require('dotenv').config()
const settingsDb = require('../src/model/settings/settings.db')
const wikiDb = require('../src/model/wiki/wiki.db')
const users = require('../src/model/users/users.model')
const { ensureSchema, close } = require('../src/utils/db')
const log = require('../src/utils/logger')('seed')
// Default settings — only inserted if the key does not already exist.
const DEFAULT_SETTINGS = {
site_mode: 'maintenance', // start safe: site is in maintenance until set live
site_mode_changed_at: '',
site_mode_changed_by: '',
maintenance_message:
'Mysticmoon is being shaped beneath a midnight sky. The site will return soon.',
status_message: 'In progress.',
homepage_teaser:
'Mysticmoon is still being shaped beneath a midnight sky. A quiet preview for ' +
'future news, screenshots, guides, and community notes as the world comes online.',
contact_email: process.env.CONTACT_TO || 'UOMysticmoon@gmail.com',
site_title: 'UOMysticmoon',
}
// The 8 starter wiki categories (editable later via the admin panel).
const WIKI_PAGES = [
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.'],
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.'],
['systems', 'Server Systems', 'Shard mechanics and custom features.'],
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.'],
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.'],
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.'],
['lore', 'Lore', 'Stories, places, factions, and mysteries.'],
['rules', 'Rules', 'Player conduct, shard expectations, and policies.'],
]
async function seedDefaults() {
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
await settingsDb.seedDefault(key, value)
}
for (const [slug, title, body] of WIKI_PAGES) {
await wikiDb.seedDefault(slug, title, body)
}
log.info('settings and wiki defaults ensured')
}
// Create the first admin from env vars, only when no users exist yet.
async function createInitialAdminFromEnv() {
const { ADMIN_USERNAME, ADMIN_PASSWORD } = process.env
if (!ADMIN_USERNAME || !ADMIN_PASSWORD) return
if ((await users.count()) > 0) return
await users.createUser({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD, role: 'admin' })
log.info(`created initial admin "${ADMIN_USERNAME}"`)
}
// Allow running standalone: `npm run seed`
if (require.main === module) {
;(async () => {
try {
await ensureSchema()
await seedDefaults()
await createInitialAdminFromEnv()
} catch (err) {
log.error('seed failed', err)
process.exitCode = 1
} finally {
await close()
}
})()
}
module.exports = { seedDefaults, createInitialAdminFromEnv }

1659
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
server/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "uomysticmoon-server",
"version": "1.0.0",
"description": "REST API for the UOMysticmoon website and admin panel",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"seed": "node db/seed.js",
"test": "echo \"no tests yet\" && exit 0"
},
"keywords": ["express", "mariadb", "jwt", "bcrypt"],
"author": "whitlocktech",
"license": "ISC",
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"express-validator": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"mariadb": "^3.3.1",
"morgan": "^1.10.0",
"multer": "^2.0.1",
"nodemailer": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}

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 }