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:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
45
.env.example
Normal 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
32
.gitignore
vendored
Normal 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
330
BACKEND_DESIGN.md
Normal 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
28
Dockerfile
Normal 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
88
README.md
Normal 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
44
docker-compose.yml
Normal 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
19
package.json
Normal 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
36
server/.env.example
Normal 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
59
server/db/schema.sql
Normal 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
72
server/db/seed.js
Normal 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
1659
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
server/package.json
Normal file
33
server/package.json
Normal 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
91
server/src/app.js
Normal 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
|
||||||
7
server/src/middleware/noindex.js
Normal file
7
server/src/middleware/noindex.js
Normal 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
|
||||||
35
server/src/middleware/rateLimit.js
Normal file
35
server/src/middleware/rateLimit.js
Normal 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 }
|
||||||
31
server/src/middleware/siteMode.js
Normal file
31
server/src/middleware/siteMode.js
Normal 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
|
||||||
12
server/src/middleware/validate.js
Normal file
12
server/src/middleware/validate.js
Normal 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
|
||||||
20
server/src/model/activity/activity.db.js
Normal file
20
server/src/model/activity/activity.db.js
Normal 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 }
|
||||||
25
server/src/model/activity/activity.model.js
Normal file
25
server/src/model/activity/activity.model.js
Normal 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 }
|
||||||
84
server/src/model/posts/posts.db.js
Normal file
84
server/src/model/posts/posts.db.js
Normal 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,
|
||||||
|
}
|
||||||
89
server/src/model/posts/posts.model.js
Normal file
89
server/src/model/posts/posts.model.js
Normal 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,
|
||||||
|
}
|
||||||
25
server/src/model/settings/settings.db.js
Normal file
25
server/src/model/settings/settings.db.js
Normal 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 }
|
||||||
43
server/src/model/settings/settings.model.js
Normal file
43
server/src/model/settings/settings.model.js
Normal 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 }
|
||||||
67
server/src/model/users/users.db.js
Normal file
67
server/src/model/users/users.db.js
Normal 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,
|
||||||
|
}
|
||||||
73
server/src/model/users/users.model.js
Normal file
73
server/src/model/users/users.model.js
Normal 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,
|
||||||
|
}
|
||||||
46
server/src/model/wiki/wiki.db.js
Normal file
46
server/src/model/wiki/wiki.db.js
Normal 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,
|
||||||
|
}
|
||||||
25
server/src/model/wiki/wiki.model.js
Normal file
25
server/src/model/wiki/wiki.model.js
Normal 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 }
|
||||||
9
server/src/router/api.router.js
Normal file
9
server/src/router/api.router.js
Normal 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
|
||||||
357
server/src/router/v1/admin/admin.controller.js
Normal file
357
server/src/router/v1/admin/admin.controller.js
Normal 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,
|
||||||
|
}
|
||||||
113
server/src/router/v1/admin/admin.routes.js
Normal file
113
server/src/router/v1/admin/admin.routes.js
Normal 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
|
||||||
47
server/src/router/v1/auth/auth.controller.js
Normal file
47
server/src/router/v1/auth/auth.controller.js
Normal 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 }
|
||||||
22
server/src/router/v1/auth/auth.routes.js
Normal file
22
server/src/router/v1/auth/auth.routes.js
Normal 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
|
||||||
90
server/src/router/v1/public/public.controller.js
Normal file
90
server/src/router/v1/public/public.controller.js
Normal 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,
|
||||||
|
}
|
||||||
30
server/src/router/v1/public/public.routes.js
Normal file
30
server/src/router/v1/public/public.routes.js
Normal 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
|
||||||
13
server/src/router/v1/v1.router.js
Normal file
13
server/src/router/v1/v1.router.js
Normal 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
73
server/src/server.js
Normal 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
96
server/src/utils/auth.js
Normal 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
78
server/src/utils/db.js
Normal 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 }
|
||||||
94
server/src/utils/logger.js
Normal file
94
server/src/utils/logger.js
Normal 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
|
||||||
41
server/src/utils/mailer.js
Normal file
41
server/src/utils/mailer.js
Normal 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 }
|
||||||
Reference in New Issue
Block a user