Reviewed-on: UOM/website#15 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
UOMysticmoon Website
Public site, wiki, and protected admin panel for the UOMysticmoon private Ultima Online shard — a full-stack app in one repo:
- Backend — Node.js + Express REST API (layered
router → controller → model → db), MariaDB, JWT-in-cookie auth. - Frontend — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- Deploy — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
The design reference is BACKEND_DESIGN.md (API contract, schema, security).
Contents
- Tech stack
- Project structure
- Prerequisites
- Setup & run
- First admin & site mode
- Pages & routes
- API endpoints
- Environment variables
- Security
- Logging
- Deployment behind Pangolin
Tech stack
| Layer | Tech |
|---|---|
| Backend | Node.js 20+, Express 4, mariadb driver (parameterized SQL, no ORM) |
| Auth | JWT in an httpOnly cookie, bcrypt password hashing |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
Nodemailer (SMTP) with a mailto: fallback |
|
| Deploy | Docker Compose, Pangolin reverse proxy |
Project structure
UOMSITE/
├─ server/ Express API
│ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ router/v1/ auth / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · validate
│ │ └─ utils/ auth (JWT/cookies) · db (pool) · mailer · logger
│ ├─ db/ schema.sql + seed.js
│ └─ .env.example
├─ client/ React + Vite SPA
│ ├─ src/
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, …
│ │ ├─ contexts/ AuthContext, SiteContext
│ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens
│ └─ public/assets/img/ hero image
├─ Dockerfile builds client → serves via Express
├─ docker-compose.yml app + MariaDB
├─ .env.example root env (used by Compose)
└─ package.json workspace scripts
Prerequisites
- Node.js 20+ and npm (Node 22/24 are fine).
- Docker Desktop (for MariaDB, and for the full Compose deploy).
Setup & run
Option A — Docker Compose (full stack)
The simplest way to run everything. The image installs server deps, builds the React client, and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are created automatically on first boot.
cp .env.example .env
# Edit .env and set at least:
# DB_PASSWORD, DB_ROOT_PASSWORD (any strong values)
# JWT_SECRET (a long random string)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
docker compose up -d --build
- App: http://localhost:3000 (binds
0.0.0.0) - Health check:
GET http://localhost:3000/api/health→{ "status": "ok" } - Logs:
docker compose logs -f app(and./logs/app.logon the host) - Stop:
docker compose down(add-vto also wipe the database + uploads volumes)
Option B — Local development (hot reload)
Run the API and the Vite dev server separately. The Vite server proxies /api and /uploads
to the backend, so the SPA stays same-origin (cookies work).
1. Start a MariaDB the backend can reach (published on localhost:3306):
docker run -d --name uomm-db -p 3306:3306 -e MARIADB_DATABASE=uomysticmoon -e MARIADB_USER=uomm -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
2. Configure + start the backend (terminal 1):
cp server/.env.example server/.env
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass,
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
npm run install-server
npm run server # nodemon → http://localhost:3000
3. Start the frontend (terminal 2):
npm run install-client
npm run client # Vite → http://localhost:5173
Develop at http://localhost:5173 (hot reload). On Windows, the Vite proxy targets
127.0.0.1:3000 to avoid the IPv6-localhost pitfall.
Tip:
npm run install-allinstalls both server and client deps in one go.
Option C — Production build without Docker
Build the SPA and let Express serve it on a single port (still needs a MariaDB + server/.env):
npm run install-all
npm run build # → client/dist
npm start # node server → serves API + SPA at http://localhost:3000
First admin & site mode
- On first boot, if the
userstable is empty andADMIN_USERNAME/ADMIN_PASSWORDare set, the first admin is created automatically. You can also runnpm run seed. After it exists you may blank those env vars. - The site starts in
maintenancemode: public visitors see the polished "coming soon" page; the admin login and panel are always reachable. - Sign in at
/admin/login, then flip Maintenance → Live from the Dashboard. A logged-in admin can preview the live site even while it's in maintenance.
Pages & routes
Public (gated by site mode):
| Route | Page |
|---|---|
/ |
Portal landing (hero + destinations) |
/site |
Website index (section cards) |
/site/news |
News feed |
/site/screenshots |
Screenshot gallery |
/site/five-on-friday |
Five on Friday |
/site/newsletter · /site/newsletter/:id |
Newsletter list + issue |
/site/about · /site/status |
About · Shard status |
/wiki · /wiki/:slug |
Wiki landing + article (auto table-of-contents) |
Admin (cookie auth, noindex):
| Route | View |
|---|---|
/admin/login |
Sign in |
/admin |
Dashboard (mode toggle, stats, recent activity) |
/admin/posts |
Posts CRUD + publish + image upload |
/admin/wiki |
Wiki pages CRUD |
/admin/settings |
Site settings |
/admin/activity |
Activity log |
/admin/users |
User management |
API endpoints
| Group | Base | Auth |
|---|---|---|
| Auth | /api/v1/auth (login, logout, me) |
cookie |
| Public | /api/v1/public (settings, status, posts/:category, posts/:category/:idOrSlug, wiki, wiki/:slug, contact) |
none |
| Admin | /api/v1/admin (dashboard, site-mode, posts, posts/upload, wiki, settings, activity, users) |
cookie (admin) |
Post categories (URL form): news, five-on-friday, newsletter, screenshots.
See BACKEND_DESIGN.md §4 for the full contract.
Environment variables
Copy .env.example (Compose) or server/.env.example (local) and fill in. .env is git-ignored.
| Var | Default | Notes |
|---|---|---|
NODE_ENV |
production |
|
PORT |
3000 |
server listens on 0.0.0.0:PORT |
DB_HOST / DB_PORT |
db / 3306 |
db in Compose; 127.0.0.1 for local dev |
DB_NAME / DB_USER / DB_PASSWORD |
uomysticmoon / uomm / — |
app database credentials |
DB_ROOT_PASSWORD |
— | MariaDB root (Compose only) |
JWT_SECRET |
— | required — long random string |
JWT_EXPIRES_IN |
1d |
token + cookie lifetime |
COOKIE_SECURE |
auto |
auto = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
COOKIE_NAME |
uomm_token |
|
ADMIN_USERNAME / ADMIN_PASSWORD |
— | first-admin bootstrap (first boot only) |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS |
— | optional; blank → contact form uses mailto: |
CONTACT_TO |
UOMysticmoon@gmail.com |
contact recipient |
CLIENT_ORIGIN |
http://localhost:5173 |
enables CORS in dev only |
LOG_LEVEL / FILE_LOG_LEVEL |
info / debug |
console / file verbosity |
LOG_TO_FILE / LOG_DIR / LOG_FILE |
true / <server>/logs / app.log |
log file (bind-mounted to ./logs in Docker) |
Security
JWT in an httpOnly, SameSite=Lax cookie (Secure auto-detected) · bcrypt hashing · login &
contact rate limiting · express-validator on writes · helmet · admin routes noindex +
robots.txt disallow · trust proxy for correct client IPs behind Pangolin · first admin seeded
from env (no hardcoded credentials) · .env git-ignored. Passwords and request bodies are never
logged. SMTP is optional — the contact form falls back to a mailto: link when unconfigured.
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":"..."}
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. Console
verbosity is LOG_LEVEL; the file keeps the fuller FILE_LOG_LEVEL record. In Docker the file is
bind-mounted to ./logs/app.log and docker compose logs -f app shows the console stream.
Deployment behind Pangolin
docker compose up -d --build exposes the app container on 0.0.0.0:3000 (no 127.0.0.1
binding) so Pangolin can reach it. Point a Pangolin resource at app:3000. Because COOKIE_SECURE
defaults to auto, the admin login works both directly via the LAN IP over HTTP and through
Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network
(no published port by default); data persists in the dbdata volume, uploads in uploads.