# 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](BACKEND_DESIGN.md) (API contract, schema, security). --- ## Contents - [Tech stack](#tech-stack) - [Project structure](#project-structure) - [Prerequisites](#prerequisites) - [Setup & run](#setup--run) - [Option A — Docker Compose (full stack)](#option-a--docker-compose-full-stack) - [Option B — Local development (hot reload)](#option-b--local-development-hot-reload) - [Option C — Production build without Docker](#option-c--production-build-without-docker) - [First admin & site mode](#first-admin--site-mode) - [Pages & routes](#pages--routes) - [API endpoints](#api-endpoints) - [Environment variables](#environment-variables) - [Security](#security) - [Logging](#logging) - [Deployment behind Pangolin](#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 | | Email | 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. ```bash 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.log` on the host) - Stop: `docker compose down` (add `-v` to 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`): ```bash 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): ```bash cp server/.env.example server/.env # Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass, # JWT_SECRET=, ADMIN_USERNAME=admin, ADMIN_PASSWORD= npm run install-server npm run server # nodemon → http://localhost:3000 ``` **3. Start the frontend** (terminal 2): ```bash 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-all` installs 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`): ```bash 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 `users` table is empty and `ADMIN_USERNAME` / `ADMIN_PASSWORD` are set, the first admin is created automatically. You can also run `npm run seed`. After it exists you may blank those env vars. - The site **starts in `maintenance` mode**: 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](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` / `/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`.