From eef79e24031e9cbb075f79ec442dc5b277840d41 Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Fri, 26 Jun 2026 20:58:32 -0500 Subject: [PATCH] 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 --- .dockerignore | 14 + .env.example | 45 + .gitignore | 32 + BACKEND_DESIGN.md | 330 ++++ Dockerfile | 28 + README.md | 88 + docker-compose.yml | 44 + package.json | 19 + server/.env.example | 36 + server/db/schema.sql | 59 + server/db/seed.js | 72 + server/package-lock.json | 1659 +++++++++++++++++ server/package.json | 33 + server/src/app.js | 91 + server/src/middleware/noindex.js | 7 + server/src/middleware/rateLimit.js | 35 + server/src/middleware/siteMode.js | 31 + server/src/middleware/validate.js | 12 + server/src/model/activity/activity.db.js | 20 + server/src/model/activity/activity.model.js | 25 + server/src/model/posts/posts.db.js | 84 + server/src/model/posts/posts.model.js | 89 + server/src/model/settings/settings.db.js | 25 + server/src/model/settings/settings.model.js | 43 + server/src/model/users/users.db.js | 67 + server/src/model/users/users.model.js | 73 + server/src/model/wiki/wiki.db.js | 46 + server/src/model/wiki/wiki.model.js | 25 + server/src/router/api.router.js | 9 + .../src/router/v1/admin/admin.controller.js | 357 ++++ server/src/router/v1/admin/admin.routes.js | 113 ++ server/src/router/v1/auth/auth.controller.js | 47 + server/src/router/v1/auth/auth.routes.js | 22 + .../src/router/v1/public/public.controller.js | 90 + server/src/router/v1/public/public.routes.js | 30 + server/src/router/v1/v1.router.js | 13 + server/src/server.js | 73 + server/src/utils/auth.js | 96 + server/src/utils/db.js | 78 + server/src/utils/logger.js | 94 + server/src/utils/mailer.js | 41 + 41 files changed, 4195 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 BACKEND_DESIGN.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 server/.env.example create mode 100644 server/db/schema.sql create mode 100644 server/db/seed.js create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/app.js create mode 100644 server/src/middleware/noindex.js create mode 100644 server/src/middleware/rateLimit.js create mode 100644 server/src/middleware/siteMode.js create mode 100644 server/src/middleware/validate.js create mode 100644 server/src/model/activity/activity.db.js create mode 100644 server/src/model/activity/activity.model.js create mode 100644 server/src/model/posts/posts.db.js create mode 100644 server/src/model/posts/posts.model.js create mode 100644 server/src/model/settings/settings.db.js create mode 100644 server/src/model/settings/settings.model.js create mode 100644 server/src/model/users/users.db.js create mode 100644 server/src/model/users/users.model.js create mode 100644 server/src/model/wiki/wiki.db.js create mode 100644 server/src/model/wiki/wiki.model.js create mode 100644 server/src/router/api.router.js create mode 100644 server/src/router/v1/admin/admin.controller.js create mode 100644 server/src/router/v1/admin/admin.routes.js create mode 100644 server/src/router/v1/auth/auth.controller.js create mode 100644 server/src/router/v1/auth/auth.routes.js create mode 100644 server/src/router/v1/public/public.controller.js create mode 100644 server/src/router/v1/public/public.routes.js create mode 100644 server/src/router/v1/v1.router.js create mode 100644 server/src/server.js create mode 100644 server/src/utils/auth.js create mode 100644 server/src/utils/db.js create mode 100644 server/src/utils/logger.js create mode 100644 server/src/utils/mailer.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0431799 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +**/node_modules +**/dist +.git +.env +*.env +!.env.example +_reference +server/uploads +uploads +server/logs +logs +*.log +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f72080e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9927d08 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/BACKEND_DESIGN.md b/BACKEND_DESIGN.md new file mode 100644 index 0000000..4bf9c9a --- /dev/null +++ b/BACKEND_DESIGN.md @@ -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 `/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 | +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0395e3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c84674 --- /dev/null +++ b/README.md @@ -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` | `/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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f0306a5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d93da5 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..b2dd4e0 --- /dev/null +++ b/server/.env.example @@ -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 /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 diff --git a/server/db/schema.sql b/server/db/schema.sql new file mode 100644 index 0000000..8a2f885 --- /dev/null +++ b/server/db/schema.sql @@ -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; diff --git a/server/db/seed.js b/server/db/seed.js new file mode 100644 index 0000000..67dd2cb --- /dev/null +++ b/server/db/seed.js @@ -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 } diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..1723bf8 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1659 @@ +{ + "name": "uomysticmoon-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uomysticmoon-server", + "version": "1.0.0", + "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" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mariadb": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz", + "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==", + "license": "LGPL-2.1-or-later", + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/node": ">=20", + "denque": "^2.1.0", + "iconv-lite": "^0.7.2", + "lru-cache": "^11.5.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/mariadb/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/morgan": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..6921209 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 0000000..8f70ef0 --- /dev/null +++ b/server/src/app.js @@ -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( + '

UOMysticmoon API

The web client has not been built yet. ' + + 'The API is available under /api/v1.

', + ), + ) +} + +// ── 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 diff --git a/server/src/middleware/noindex.js b/server/src/middleware/noindex.js new file mode 100644 index 0000000..fc905f7 --- /dev/null +++ b/server/src/middleware/noindex.js @@ -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 diff --git a/server/src/middleware/rateLimit.js b/server/src/middleware/rateLimit.js new file mode 100644 index 0000000..283ff1f --- /dev/null +++ b/server/src/middleware/rateLimit.js @@ -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 } diff --git a/server/src/middleware/siteMode.js b/server/src/middleware/siteMode.js new file mode 100644 index 0000000..28b2c56 --- /dev/null +++ b/server/src/middleware/siteMode.js @@ -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 diff --git a/server/src/middleware/validate.js b/server/src/middleware/validate.js new file mode 100644 index 0000000..16c390d --- /dev/null +++ b/server/src/middleware/validate.js @@ -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 diff --git a/server/src/model/activity/activity.db.js b/server/src/model/activity/activity.db.js new file mode 100644 index 0000000..83bc969 --- /dev/null +++ b/server/src/model/activity/activity.db.js @@ -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 } diff --git a/server/src/model/activity/activity.model.js b/server/src/model/activity/activity.model.js new file mode 100644 index 0000000..0d67744 --- /dev/null +++ b/server/src/model/activity/activity.model.js @@ -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 } diff --git a/server/src/model/posts/posts.db.js b/server/src/model/posts/posts.db.js new file mode 100644 index 0000000..86f66d4 --- /dev/null +++ b/server/src/model/posts/posts.db.js @@ -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, +} diff --git a/server/src/model/posts/posts.model.js b/server/src/model/posts/posts.model.js new file mode 100644 index 0000000..77bcb0a --- /dev/null +++ b/server/src/model/posts/posts.model.js @@ -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, +} diff --git a/server/src/model/settings/settings.db.js b/server/src/model/settings/settings.db.js new file mode 100644 index 0000000..012493d --- /dev/null +++ b/server/src/model/settings/settings.db.js @@ -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 } diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js new file mode 100644 index 0000000..90dbde1 --- /dev/null +++ b/server/src/model/settings/settings.model.js @@ -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 } diff --git a/server/src/model/users/users.db.js b/server/src/model/users/users.db.js new file mode 100644 index 0000000..77058c9 --- /dev/null +++ b/server/src/model/users/users.db.js @@ -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, +} diff --git a/server/src/model/users/users.model.js b/server/src/model/users/users.model.js new file mode 100644 index 0000000..5cc145a --- /dev/null +++ b/server/src/model/users/users.model.js @@ -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, +} diff --git a/server/src/model/wiki/wiki.db.js b/server/src/model/wiki/wiki.db.js new file mode 100644 index 0000000..48acb81 --- /dev/null +++ b/server/src/model/wiki/wiki.db.js @@ -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, +} diff --git a/server/src/model/wiki/wiki.model.js b/server/src/model/wiki/wiki.model.js new file mode 100644 index 0000000..42cfb5f --- /dev/null +++ b/server/src/model/wiki/wiki.model.js @@ -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 } diff --git a/server/src/router/api.router.js b/server/src/router/api.router.js new file mode 100644 index 0000000..8641623 --- /dev/null +++ b/server/src/router/api.router.js @@ -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 diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js new file mode 100644 index 0000000..47f3a1d --- /dev/null +++ b/server/src/router/v1/admin/admin.controller.js @@ -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, +} diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js new file mode 100644 index 0000000..fc76273 --- /dev/null +++ b/server/src/router/v1/admin/admin.routes.js @@ -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 diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js new file mode 100644 index 0000000..914ffc9 --- /dev/null +++ b/server/src/router/v1/auth/auth.controller.js @@ -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 } diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js new file mode 100644 index 0000000..13fd84e --- /dev/null +++ b/server/src/router/v1/auth/auth.routes.js @@ -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 diff --git a/server/src/router/v1/public/public.controller.js b/server/src/router/v1/public/public.controller.js new file mode 100644 index 0000000..2ea3d19 --- /dev/null +++ b/server/src/router/v1/public/public.controller.js @@ -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, +} diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js new file mode 100644 index 0000000..3d756fa --- /dev/null +++ b/server/src/router/v1/public/public.routes.js @@ -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 diff --git a/server/src/router/v1/v1.router.js b/server/src/router/v1/v1.router.js new file mode 100644 index 0000000..5bfa37f --- /dev/null +++ b/server/src/router/v1/v1.router.js @@ -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 diff --git a/server/src/server.js b/server/src/server.js new file mode 100644 index 0000000..e34aab4 --- /dev/null +++ b/server/src/server.js @@ -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) +}) diff --git a/server/src/utils/auth.js b/server/src/utils/auth.js new file mode 100644 index 0000000..327e291 --- /dev/null +++ b/server/src/utils/auth.js @@ -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, +} diff --git a/server/src/utils/db.js b/server/src/utils/db.js new file mode 100644 index 0000000..3ccba1f --- /dev/null +++ b/server/src/utils/db.js @@ -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 } diff --git a/server/src/utils/logger.js b/server/src/utils/logger.js new file mode 100644 index 0000000..aae6624 --- /dev/null +++ b/server/src/utils/logger.js @@ -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 /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 diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js new file mode 100644 index 0000000..6219d13 --- /dev/null +++ b/server/src/utils/mailer.js @@ -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 }