Flip M7 Part 2 to landed (Android-app#15) and record the two implementation decisions: the direct-ntfy embedded distributor (no UnifiedPush library — the plan's stated likely path; foreground-service SSE, no second app, no Google Play Services), and the small additive push.ntfyUrl settings field the app needs to discover the relay (website#79). Document push.ntfyUrl + NTFY_PUBLIC_URL in BACKEND_DESIGN.md. Co-Authored-By: Claude <noreply@anthropic.com>
426 lines
24 KiB
Markdown
426 lines
24 KiB
Markdown
# 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 | |
|
|
|
|
### password_resets — self-service reset links
|
|
| col | type | notes |
|
|
|---|---|---|
|
|
| id | INT PK AUTO_INCREMENT | |
|
|
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; **plaintext never stored** |
|
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets |
|
|
| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic `markUsed`) |
|
|
| requested_ip | VARCHAR(64) NULL | who asked (audit only) |
|
|
| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this |
|
|
| created_at / used_at | DATETIME | |
|
|
|
|
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
|
|
A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
|
|
|
### push_devices — opt-in push endpoints (M7)
|
|
| col | type | notes |
|
|
|---|---|---|
|
|
| id | INT PK AUTO_INCREMENT | |
|
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
|
|
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
|
|
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
|
|
| platform | VARCHAR(40) NULL | free-form label, e.g. `android` |
|
|
| created_at / last_seen_at | DATETIME | |
|
|
|
|
`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert.
|
|
|
|
### notification_subscriptions — which streams a user opted into (M7)
|
|
| col | type | notes |
|
|
|---|---|---|
|
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
|
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
|
|
| created_at | DATETIME | |
|
|
|
|
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
|
replaces the whole set. Nothing is pushed unless the user subscribed.
|
|
|
|
---
|
|
|
|
## 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 / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
|
|
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
|
|
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
|
| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. |
|
|
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
|
|
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
|
|
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
|
|
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) |
|
|
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
|
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
|
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
|
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
|
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
|
|
|
|
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
|
|
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
|
|
(no logic duplication) behind `requireAuth` **only** — any active account, never a specific role. This
|
|
lets a client (the Android app) manage its own account through one surface without ever touching
|
|
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
|
|
for web back-compat.
|
|
|
|
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
|
|
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
|
|
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
|
|
reset link points at the web front end (`/account/reset/:token`); the Android app hands off here
|
|
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
|
|
by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites.
|
|
|
|
**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and
|
|
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
|
|
**content-free tickle** — `{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
|
|
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
|
|
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
|
|
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
|
|
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
|
|
`config/notificationStreams.js`. Security invariants:
|
|
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
|
|
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
|
|
push.
|
|
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
|
|
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
|
|
`/player/shard/*`).
|
|
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
|
|
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
|
|
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
|
|
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
|
|
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
|
|
|
|
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
|
| Method | Path | Notes |
|
|
|---|---|---|
|
|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
|
|
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
|
|
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
|
|
| 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) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
|
|
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
|
|
flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
|
|
singleton (never returned over the API). The OAuth client id/secret are reused from the `google`
|
|
auth-providers row. Recipient is the `contact_email` site setting. If email is unconfigured/disabled,
|
|
`POST /public/contact` returns `{fallback:"mailto", email}` so the client renders a `mailto:` link
|
|
instead. Errors never leak credentials.
|
|
|
|
---
|
|
|
|
## 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.
|
|
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
|
|
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, **no
|
|
published host port** — devices reach it via the reverse proxy; the backend publisher reaches it
|
|
over the private compose network. Anonymous read-write to unguessable topics (no accounts to
|
|
provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a
|
|
working push relay with **zero interactive setup**.
|
|
- Volumes: `dbdata`, `uploads`, `ntfydata`.
|
|
|
|
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=
|
|
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
|
|
CLIENT_ORIGIN=http://localhost:5173
|
|
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
|
|
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
|
|
NTFY_BASE_URL=https://ntfy.example.com
|
|
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
|
|
# (the app registers its topic endpoint here). Defaults to the first
|
|
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
|
|
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
|
|
# shows push as unavailable for the shard.
|
|
NTFY_PUBLIC_URL=https://ntfy.example.com
|
|
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
|
```
|
|
|
|
`.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 |
|
|
```
|