diff --git a/.gitignore b/.gitignore index 9927d08..710383b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db .claude/settings.local.json .vscode/ .idea/ + +# scratch / temp scripts +_*.ps1 diff --git a/README.md b/README.md index 6c84674..e9b837a 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,242 @@ # 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. +Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online +shard — a full-stack app in one repo: -> 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. +- **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. -## Layout +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 ``` -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 +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 ``` -## Quick start (local dev) +--- + +## 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 -# 1. Start a MariaDB (or use your own and set DB_* in server/.env) -docker compose up -d db +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) -# 2. Configure + install -cp server/.env.example server/.env # edit DB_*, JWT_SECRET, ADMIN_USERNAME/PASSWORD +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 - -# 3. Run the API (creates tables, seeds defaults + first admin on boot) -npm run server # http://localhost:3000 (API at /api/v1) +npm run server # nodemon → http://localhost:3000 ``` -`GET /api/health` → `{ "status": "ok" }` confirms it's up. - -## Deploy (Docker Compose) +**3. Start the frontend** (terminal 2): ```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 +npm run install-client +npm run client # Vite → http://localhost:5173 ``` -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. +Develop at **http://localhost:5173** (hot reload). On Windows, the Vite proxy targets +`127.0.0.1:3000` to avoid the IPv6-`localhost` pitfall. -## Key endpoints +> 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`, `wiki`, `contact`) | none | -| Admin | `/api/v1/admin` (dashboard, site-mode, posts, wiki, settings, activity, users) | cookie (admin) | +| 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) | -See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the complete contract. +Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. +See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full 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. +## 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 @@ -71,18 +250,18 @@ Every log line goes to **both the console and a log file**, timestamped and leve 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. +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. -| 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` | `/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. +## 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`. diff --git a/client/vite.config.js b/client/vite.config.js index ae8c37d..46b418f 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -9,8 +9,8 @@ export default defineConfig({ server: { port: 5173, proxy: { - '/api': { target: 'http://localhost:3000', changeOrigin: true }, - '/uploads': { target: 'http://localhost:3000', changeOrigin: true }, + '/api': { target: 'http://127.0.0.1:3000', changeOrigin: true }, + '/uploads': { target: 'http://127.0.0.1:3000', changeOrigin: true }, }, }, build: { diff --git a/server/_setup.ps1 b/server/_setup.ps1 deleted file mode 100644 index d835bf9..0000000 --- a/server/_setup.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -$ErrorActionPreference = 'Stop' -$base = 'http://127.0.0.1:3000' -$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession - -function Req($method, $path, $bodyObj) { - $p = @{ Method = $method; Uri = "$base$path"; UseBasicParsing = $true; WebSession = $session; ErrorAction = 'Stop' } - if ($null -ne $bodyObj) { $p.Body = ($bodyObj | ConvertTo-Json -Depth 6 -Compress); $p.ContentType = 'application/json' } - Invoke-WebRequest @p -} - -Write-Output 'waiting for health...' -$up = $false -for ($i = 0; $i -lt 40; $i++) { - try { if ((Invoke-WebRequest "$base/api/health" -UseBasicParsing).StatusCode -eq 200) { $up = $true; break } } catch {} - Start-Sleep -Seconds 2 -} -if (-not $up) { Write-Output 'server never came up'; return } - -Req POST '/api/v1/auth/login' @{ username = 'admin'; password = 'adminpass123' } | Out-Null -Req PUT '/api/v1/admin/site-mode' @{ mode = 'live' } | Out-Null - -# News -Req POST '/api/v1/admin/posts' @{ category='news'; title='The world map enters closed testing'; excerpt='The Mysticmoon overworld is feature-complete enough for a small group to wander.'; body='

Travel between the three starting towns is live, moongates are seeded, and the first dungeon level is open for stress-testing.

'; published=$true } | Out-Null -Req POST '/api/v1/admin/posts' @{ category='news'; title='Crafting trees and resource gathering'; excerpt='Mining, lumberjacking, and the first tier of smithing and tailoring are in.'; body='

Resource respawn timers are tuned for a small population.

'; published=$true } | Out-Null - -# Five on Friday -Req POST '/api/v1/admin/posts' @{ category='five-on-friday'; title='Five on Friday #07'; body='
  1. Moongates now route correctly between all three regions.
  2. The blacksmith UI got a readability pass.
  3. Something large now lurks in the Hollow Deeps.
  4. Next week: player housing placement rules.
  5. The full-moon lighting in town looks lovely.
'; published=$true } | Out-Null - -# Newsletter -Req POST '/api/v1/admin/posts' @{ category='newsletter'; slug='june-2026'; title='A world you can walk across'; excerpt='The longest month of building yet - here is everything that landed.'; body='

June was the month Mysticmoon stopped being a set of disconnected systems and started feeling like a place.

The overworld opens

For the first time, testers walked the road from Mistholme to the eastern moongate.

Crafting takes root

Mining, lumberjacking, smithing, and tailoring all came online.

'; published=$true } | Out-Null - -# Screenshot (uses the hero image already served at /assets) -Req POST '/api/v1/admin/posts' @{ category='screenshots'; title='Mistholme at dusk'; excerpt='The square at Mistholme, lanterns lit at dusk.'; image_url='/assets/img/uomysticmoon-main-hero.png'; published=$true } | Out-Null - -# Wiki body with H2 sections so the article TOC renders -Req PUT '/api/v1/admin/wiki/new-player-guide' @{ title='New Player Guide'; body='

Everything you need to find your feet in your first hour on Mysticmoon.

When you arrive

New characters wake in Mistholme, the central starting town.

Choosing your first skills

You do not pick a class - you grow into one by using skills.

Staying alive

  • Towns are safe. The wilderness is not.
  • Bank your gold and reagents often.
  • Keep bandages and a spare weapon.
' } | Out-Null - -Write-Output 'SETUP DONE - site is live with sample content'