From 2edf577faf86dcec0aec1d9381cd1b045b7202da Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Sat, 4 Jul 2026 15:15:19 +0300 Subject: [PATCH] Add dev/prod environments with TrueNAS Docker production deploy. Split PostgreSQL into gallery_dev and gallery_prod, add Docker/Gitea deploy tooling, SMB image sync, pgAdmin split script, dev:web on Keenetic :5173, and operator docs. Co-authored-by: Cursor --- .env.example | 15 +- .gitignore | 4 + Documentation/API.md | 23 +- Documentation/DB_structure.md | 10 +- Documentation/basics.md | 33 ++- Documentation/data-and-images.md | 10 + Documentation/environments.md | 316 ++++++++++++++++++++++++ Documentation/setup.md | 73 +++--- README.md | 34 ++- client/vite.config.ts | 2 +- db/split-dev-prod-pgadmin.sql | 126 ++++++++++ db/split-dev-prod.sql | 27 ++ deploy/gallery.service | 3 +- deploy/nginx-gallery.conf | 5 +- infra/docker/.env.prod.example | 14 ++ infra/docker/DEPLOY-truenas.md | 149 +++++++++++ infra/docker/Dockerfile | 46 ++++ infra/docker/build-push-lan.ps1 | 36 +++ infra/docker/compose.prod.yaml | 19 ++ infra/docker/compose.truenas.yaml | 34 +++ infra/docker/push-lan.ps1 | 64 +++++ infra/docker/save-for-truenas.ps1 | 17 ++ infra/docker/truenas-load-image.sh | 17 ++ infra/docker/truenas-setup.sh | 11 + infra/docker/truenas-verify.sh | 23 ++ infra/scripts/sync-images-from-prod.ps1 | 42 ++++ infra/scripts/sync-images-to-prod.ps1 | 49 ++++ package.json | 11 + scripts/backup-db-data.js | 135 ++++++++++ scripts/db-env.js | 103 ++++++++ scripts/dev-web.js | 43 ++++ scripts/restore-db-data.js | 104 ++++++++ scripts/split-dev-prod-databases.js | 143 +++++++++++ scripts/sync-prod-to-dev.js | 81 ++++++ 34 files changed, 1742 insertions(+), 80 deletions(-) create mode 100644 Documentation/environments.md create mode 100644 db/split-dev-prod-pgadmin.sql create mode 100644 db/split-dev-prod.sql create mode 100644 infra/docker/.env.prod.example create mode 100644 infra/docker/DEPLOY-truenas.md create mode 100644 infra/docker/Dockerfile create mode 100644 infra/docker/build-push-lan.ps1 create mode 100644 infra/docker/compose.prod.yaml create mode 100644 infra/docker/compose.truenas.yaml create mode 100644 infra/docker/push-lan.ps1 create mode 100644 infra/docker/save-for-truenas.ps1 create mode 100644 infra/docker/truenas-load-image.sh create mode 100644 infra/docker/truenas-setup.sh create mode 100644 infra/docker/truenas-verify.sh create mode 100644 infra/scripts/sync-images-from-prod.ps1 create mode 100644 infra/scripts/sync-images-to-prod.ps1 create mode 100644 scripts/backup-db-data.js create mode 100644 scripts/db-env.js create mode 100644 scripts/dev-web.js create mode 100644 scripts/restore-db-data.js create mode 100644 scripts/split-dev-prod-databases.js create mode 100644 scripts/sync-prod-to-dev.js diff --git a/.env.example b/.env.example index 6d4322f..aa74e19 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,22 @@ +# Development — copy to .env (not committed) DB_HOST=192.168.10.122 DB_PORT=5432 DB_USER=gallery -DB_PASSWORD=gallery -DB_NAME=Gallery +DB_PASSWORD=YOUR_POSTGRES_PASSWORD +DB_NAME=gallery_dev -# Production: LAN http://192.168.10.70:3520 and reverse-proxy at gallery.mysuperlab.netcraze.pro -PORT=3520 +# Public dev URL (Keenetic → 192.168.10.70:5173) +# Local-only coding: npm run dev:server + dev:client on 3520 / 5173 instead +PORT=3451 HOST=0.0.0.0 -PUBLIC_URL=http://gallery.mysuperlab.netcraze.pro +PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro TRUST_PROXY=true IMAGE_DIR=./data/images +# Production uses infra/docker/.env.prod → gallery_prod at gallery.mysuperlab.netcraze.pro:5173 +# See Documentation/environments.md + # Optional — enable extra museum search in fetch-missing-images / search-missing-paintings # SMITHSONIAN_API_KEY= # HARVARD_ART_API_KEY= diff --git a/.gitignore b/.gitignore index 6774a01..67ce7cb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ client/node_modules/ .env .env.local .env.*.local +infra/docker/.env.prod + +# DB backups (may contain data) +db/DataBackup/ # Recovery / temp files from local restore _extracted/ diff --git a/Documentation/API.md b/Documentation/API.md index 10983ad..60a3542 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -1,16 +1,29 @@ # Art Gallery — REST API -Base URL: +Base URL (paths are the same on every host; only the origin changes): -- **Production (public):** `http://gallery.mysuperlab.netcraze.pro` -- **Production (LAN):** `http://192.168.10.70:3520` -- **Development (direct):** `http://localhost:3520` or `http://localhost:3001` depending on `PORT` in `.env` -- **Development (Vite proxy):** `http://localhost:5173` (same paths) +| Context | Base URL | +|---------|----------| +| **Production (public)** | `https://gallery.mysuperlab.netcraze.pro` | +| **Production (LAN)** | `http://192.168.10.122:5173` | +| **Development (public)** | `https://devgallery.mysuperlab.netcraze.pro` | +| **Development (LAN)** | `http://192.168.10.70:5173` | +| **Local Vite proxy** | `http://localhost:5173` (proxies `/api` and `/images` to API on `:3451`) | +| **Local API only** | `http://localhost:3451` (when using `npm run dev:web`) | + +See [environments.md](environments.md) for Keenetic rules, databases, and deploy. All JSON responses use `Content-Type: application/json`. Errors return `{ "error": "message" }` with an appropriate HTTP status. Static images are served at `/images/` from `IMAGE_DIR`. +**Quick check:** + +```powershell +curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds +curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds +``` + --- ## `GET /api/bounds` diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 89cbf7f..8d9a8e7 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -2,14 +2,20 @@ PostgreSQL schema for the virtual gallery. Canonical DDL lives in **`db/schema.sql`**; **`server/migrate.js`** (`npm run migrate`) applies that file plus idempotent incremental scripts in `db/migrate-*.sql`. This document describes the logical model. -Connection settings come from `.env` (see [setup.md](setup.md)). +Connection settings come from `.env` (dev) or `infra/docker/.env.prod` (prod scripts). See [environments.md](environments.md) and [setup.md](setup.md). + +### One-time split (legacy `Gallery` → `gallery_prod` + `gallery_dev`) + +Run [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) in **pgAdmin** on the dev PC (postgres superuser). Alternative: `npm run db:split-databases` with `PGUSER=postgres`. ## Overview | Item | Typical value | |------|----------------| | Engine | PostgreSQL 14+ | -| Database | `Gallery` | +| Database (dev) | `gallery_dev` | +| Database (prod) | `gallery_prod` | +| Legacy name | `Gallery` (one-time split → prod + dev) | | App user | `gallery` | | Time fields | Integer years (negative = BCE) | diff --git a/Documentation/basics.md b/Documentation/basics.md index 95895b1..0ac417c 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -71,32 +71,43 @@ Gallery/ ├── data/images/ # Local portraits and paintings (+ thumbs/) ├── db/ # schema.sql, setup-admin.sql, migrate-*.sql ├── server/migrate.js # npm run migrate — schema + incremental migrations -├── deploy/ # Production nginx + systemd examples +├── deploy/ # Legacy nginx + systemd examples (optional) +├── infra/docker/ # Production Dockerfile, TrueNAS compose, deploy scripts ├── Documentation/ # This folder +│ └── environments.md # Dev/prod URLs, DB split, sync, deploy └── .env # DB and port config (not committed) ``` ## Runtime modes -### Production-style (single process) +### Production (TrueNAS Docker) + +Production runs in **`gallery-web`** on TrueNAS port **5173**, database **`gallery_prod`**, public URL **https://gallery.mysuperlab.netcraze.pro**. Images: `/mnt/BasePool/Applications/Gallery/data/images` (SMB share **`Gallery`**). See [environments.md](environments.md). + +### Public development (`dev:web`) ```bash -npm run start:prod # build client + serve on PORT (default 3520) -# or: npm run build && npm run start +npm run dev:web # Vite :5173 + API :3451 — https://devgallery.mysuperlab.netcraze.pro ``` -Serves `/api/*`, `/images/*`, and the built SPA from `client/dist` if it exists. +Uses database **`gallery_dev`** on the same PostgreSQL host. -**Deployed URLs:** public http://gallery.mysuperlab.netcraze.pro · LAN http://192.168.10.70:3520 — see [setup.md](setup.md#production-deployment). - -### Development (two processes) +### Production-style single process (local) ```bash -npm run dev:server # API on PORT from .env (3520 production, 3001 typical dev) -npm run dev:client # Vite on :5173, proxies /api and /images to PORT +npm run start:prod # build client + serve on PORT from .env ``` -Use the Vite URL during frontend work for HMR. When the public domain is proxied to Vite (see [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf)), both `dev:server` and `dev:client` must stay running or visitors see **503**. +Serves `/api/*`, `/images/*`, and the built SPA from `client/dist`. + +### Local HMR (two processes) + +```bash +npm run dev:server # API on PORT from .env +npm run dev:client # Vite on :5173, proxies /api and /images +``` + +Use for fast frontend iteration without Keenetic. Legacy nginx config in [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf) proxied the public domain to Vite `:5173`. ## User navigation flow diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 1235248..141b88a 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -22,6 +22,15 @@ data/images/ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can rediscover files on disk even when DB paths are empty (`server/image-service.js` → `syncPaintingFromDisk`). +### Dev vs production image storage + +| Environment | Path on disk | Sync | +|-------------|--------------|------| +| **Development** | `./data/images/` in repo | Working copy on dev PC | +| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` | + +Promote dev → prod files: `npm run images:sync-to-prod` (after `net use \\192.168.10.122\Gallery`). Refresh dev from prod: `npm run images:sync-from-prod`. See [environments.md](environments.md). + ## Scripts overview | Script | npm command | Role | @@ -37,6 +46,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco | `update-influences.js` | `npm run update-influences` | Applies influence graph; creates missing artists/works | | `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk | | `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API | +| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run images:sync-*` | Robocopy via SMB `\\192.168.10.122\Gallery` | | `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` | | `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches | | `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows | diff --git a/Documentation/environments.md b/Documentation/environments.md new file mode 100644 index 0000000..a7d768d --- /dev/null +++ b/Documentation/environments.md @@ -0,0 +1,316 @@ +# Development and production environments + +Gallery uses **one PostgreSQL server** on TrueNAS (`192.168.10.122`) with **two databases**. The **dev PC** (`192.168.10.70`) runs `npm run dev:web` on port **5173**, published at **`https://devgallery.mysuperlab.netcraze.pro`** via Keenetic. + +| Environment | Database | Public URL | App host | +|-------------|----------|------------|----------| +| **Development** | `gallery_dev` | `https://devgallery.mysuperlab.netcraze.pro` | Dev PC `192.168.10.70:5173` | +| **Production** | `gallery_prod` | `https://gallery.mysuperlab.netcraze.pro` | TrueNAS container `192.168.10.122:5173` | + +--- + +## Where to run what (quick reference) + +| Step | Machine | Interface | Privilege | What | +|------|---------|-----------|-----------|------| +| Stop dev servers | Dev PC `192.168.10.70` | PowerShell (normal) | your user | Close `dev:web` / `dev:server` / `dev:client` terminals | +| **DB split (one-time)** | Dev PC | **pgAdmin** → Query Tool on `postgres` | **postgres superuser** | [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) step by step | +| Dev migrate / dev:web | Dev PC | PowerShell (normal) | your user | `npm run migrate`, `npm run dev:web` | +| Image dir on TrueNAS | TrueNAS `192.168.10.122` | **Shell** (SSH or UI → System Settings → Shell) | root / sudo | `bash infra/docker/truenas-setup.sh` | +| Copy images to prod | Dev PC | PowerShell (normal) | SMB `\\192.168.10.122\Gallery` | `net use` then `npm run images:sync-to-prod` | +| Build + push Docker image | Dev PC | **PowerShell as Administrator** | admin (for LAN hosts entry) | `npm run docker:publish` | +| Install prod app | TrueNAS | **Web UI** → Apps → Custom App | admin | Paste `infra/docker/compose.truenas.yaml` | +| Verify prod | Dev PC or TrueNAS | PowerShell / browser | any | `curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds` | + +You do **not** need `psql` on TrueNAS. Database work is done from **pgAdmin on the dev PC** connected to `192.168.10.122:5432`. + +--- + +## One-time setup (full walkthrough) + +### Step A — Stop Gallery on the dev PC + +**Where:** Dev PC — PowerShell or open terminal tabs (no admin needed) + +Close anything using ports **5173**, **3451**, or **3520**: + +- Stop `npm run dev:web`, `npm run dev:server`, `npm run dev:client` +- If prod container already runs on TrueNAS: **TrueNAS Web UI** → Apps → **gallery-web** → **Stop** + +### Step B — Split the database (pgAdmin) + +**Where:** Dev PC — **pgAdmin** (not TrueNAS shell) + +1. Open **pgAdmin**. +2. Add/connect to server: + - **Host:** `192.168.10.122` + - **Port:** `5432` + - **Maintenance database:** `postgres` + - **Username:** `postgres` (superuser — **not** `gallery`) + - **Password:** your postgres password +3. Tree: **Servers** → your server → **Databases** → click **`postgres`** +4. **Tools** → **Query Tool** (or right-click `postgres` → Query Tool) +5. **File** → **Open** → `Gallery\db\split-dev-prod-pgadmin.sql` +6. Run **each STEP separately** (highlight from `-- STEP N` through that section, press **F5** / Execute): + + | Step | Action | + |------|--------| + | **STEP 0** | Pre-flight — should show database `Gallery` | + | **STEP 1** | Terminate connections | + | **STEP 2** | `ALTER DATABASE "Gallery" RENAME TO gallery_prod` (use lowercase variant only if STEP 0 showed `gallery`) | + | **STEP 3** | `CREATE DATABASE gallery_dev WITH TEMPLATE gallery_prod` (wait ~1–2 min) | + | **STEP 4** | `GRANT` to user `gallery` | + | **STEP 5** | Verify — should show `gallery_prod` and `gallery_dev` | + | **STEP 6** | **New Query Tool on `gallery_dev`** (not postgres!) — `SELECT current_database()` then `SELECT count(*) FROM paintings` | + +If STEP 2 fails with “database already exists”, STEP 0 likely already shows `gallery_prod` — skip STEP 2 and continue from STEP 3 (skip STEP 3 too if `gallery_dev` exists). + +**Alternative (dev PC with psql installed):** + +```powershell +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql +``` + +**Alternative (Node, postgres password in env):** + +```powershell +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +$env:PGHOST="192.168.10.122"; $env:PGUSER="postgres"; $env:PGPASSWORD="YOUR_POSTGRES_PASSWORD" +npm run db:split-databases +``` + +### Step C — Configure dev PC and test + +**Where:** Dev PC — PowerShell (normal), repo root + +1. Edit `.env` (copy from `.env.example` if needed): + + ```env + DB_NAME=gallery_dev + PORT=3451 + PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro + ``` + +2. Run: + + ```powershell + cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery + npm run migrate + npm run dev:web + ``` + +3. Open **https://devgallery.mysuperlab.netcraze.pro** (or http://localhost:5173 on the dev PC) + +### Step D — Prepare prod image folder on TrueNAS + +**Where:** TrueNAS — **Shell** (SSH to `192.168.10.122`, or TrueNAS UI → **System Settings** → **Shell**) + +```bash +# Copy script to TrueNAS first, or paste commands manually: +mkdir -p /mnt/BasePool/Applications/Gallery/data/images/portraits +mkdir -p /mnt/BasePool/Applications/Gallery/data/images/paintings/thumbs +chown -R 1001:1001 /mnt/BasePool/Applications/Gallery +chmod -R u+rwX,g+rwX /mnt/BasePool/Applications/Gallery +``` + +Or from a checkout on TrueNAS: `bash infra/docker/truenas-setup.sh` + +### Step E — Copy images dev → prod volume + +**Where:** Dev PC — PowerShell (normal), repo root + +Requires SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on TrueNAS. + +**Connect to the share first** (once per Windows session), then sync: + +```powershell +# Map share (use your TrueNAS SMB user/password) +net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER + +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +npm run images:sync-to-prod +``` + +UNC destination: `\\192.168.10.122\Gallery\data\images` + +Type `yes` when prompted. First run copies ~1000+ files (several minutes). + +If `net use` fails, open `\\192.168.10.122\Gallery` in File Explorer and sign in, then retry. + +### Step F — Build and push Docker image + +**Where:** Dev PC — **PowerShell as Administrator** (for fast LAN push to Gitea) + +Prerequisites: **Docker Desktop running**, logged in to Gitea. + +```powershell +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +docker login gitea.mysuperlab.netcraze.pro +npm run docker:publish +``` + +Use normal PowerShell with `-SkipHosts` if you already added `192.168.10.122 gitea.mysuperlab.netcraze.pro` to `C:\Windows\System32\drivers\etc\hosts`. + +### Step G — Install production on TrueNAS + +**Where:** TrueNAS — **Web UI** (browser) + +1. **Apps** → **Discover Apps** → **Custom App** → **Install via Docker Compose** +2. Paste contents of `infra/docker/compose.truenas.yaml` from the repo +3. Replace `YOUR_POSTGRES_PASSWORD` with the `gallery` user password +4. **Apps** → **Settings** → register Gitea registry (`gitea.mysuperlab.netcraze.pro`, token with `read:package`) +5. Deploy → wait for **gallery-web** to show **Running** + +### Step H — Verify production + +**Where:** Dev PC — PowerShell (normal) or any browser + +```powershell +curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds +curl.exe -s http://192.168.10.122:5173/api/bounds +``` + +Open **https://gallery.mysuperlab.netcraze.pro/** — timeline and sample painting images should load. + +--- + +## Environment files + +| File | Git | Purpose | +|------|-----|---------| +| [`.env`](../.env) | ignored | **Dev** — `DB_NAME=gallery_dev`, `PORT=3451` | +| [`.env.example`](../.env.example) | tracked | Dev template | +| [`infra/docker/.env.prod`](../infra/docker/.env.prod) | ignored | **Prod** — migrate/restore prod scripts | +| [`infra/docker/.env.prod.example`](../infra/docker/.env.prod.example) | tracked | Prod template | +| [`infra/docker/compose.truenas.yaml`](../infra/docker/compose.truenas.yaml) | tracked | TrueNAS Custom App | + +Never point dev `.env` at `gallery_prod`. Prod scripts refuse dev env files. + +--- + +## Dev public access (Keenetic) + +**Where:** Keenetic router Web UI (not dev PC) + +| Field | Value | +|-------|-------| +| Domain | `devgallery.mysuperlab.netcraze.pro` | +| Upstream IP | `192.168.10.70` | +| Upstream port | **`5173`** | +| **Protocol to device** | **`http`** (not https) | +| Preserve Host | ON | + +Vite on the dev PC speaks **plain HTTP** only. Keenetic terminates HTTPS from the browser, then must forward **HTTP** to `192.168.10.70:5173`. + +If **Protocol to device** is `https`, Keenetic tries TLS against Vite → **502 Bad Gateway** (`Server: Web server`). + +**Verify from dev PC** (servers must be running: `npm run dev:web`): + +```powershell +# PowerShell: curl is an alias — use curl.exe for -k, or Invoke-WebRequest +curl.exe -s -o NUL -w "HTTP %{http_code}`n" http://192.168.10.70:5173/ +curl.exe -sk -o NUL -w "HTTP %{http_code}`n" https://devgallery.mysuperlab.netcraze.pro/ + +# Or native PowerShell (skip cert check): +Invoke-WebRequest -Uri https://devgallery.mysuperlab.netcraze.pro/ -SkipCertificateCheck | Select-Object StatusCode +``` + +Expect **HTTP 200** (not 502). + +**`.env`:** use the same scheme as the browser URL. If Keenetic serves HTTPS publicly: + +```env +PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro +TRUST_PROXY=true +``` + +Restart `npm run dev:web` after changing `PUBLIC_URL`. + +--- + +## Production public access (Keenetic) + +**Where:** Keenetic router Web UI + +| Field | Value | +|-------|-------| +| Domain | `gallery.mysuperlab.netcraze.pro` | +| Upstream IP | **`192.168.10.122`** (TrueNAS — not the dev PC) | +| Upstream port | **`5173`** | +| **Protocol to device** | **`http`** (container speaks HTTP, not HTTPS) | +| Preserve Host | ON | + +The prod container listens on plain HTTP on port **5173**. Keenetic terminates HTTPS from the browser and must forward **HTTP** to TrueNAS. + +**Verify the app first (bypass Keenetic):** + +```powershell +curl.exe -s http://192.168.10.122:5173/api/bounds +``` + +Expect JSON immediately (~200). If this works but the public URL returns **502/504**, the Keenetic rule is wrong (IP, port, or `https` to device). + +**Then verify public URL:** + +```powershell +curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds +``` + +See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documentation/gitea-https-keenetic-npm-setup.md) — same Keenetic HTTPS → HTTP upstream pattern. + +--- + +## Daily development + +**Where:** Dev PC — PowerShell (normal), repo root + +| Task | Command | +|------|---------| +| Public dev URL (Keenetic) | `npm run dev:web` | +| Fast local HMR (no Keenetic) | `npm run dev:server` + `npm run dev:client` | +| Refresh dev DB from prod | `npm run db:sync-from-prod` | +| Pull prod images to dev | `npm run images:sync-from-prod` | + +--- + +## Promote dev → prod + +**Where:** Dev PC unless noted + +1. `npm run db:backup` +2. Test on https://devgallery.mysuperlab.netcraze.pro +3. `npm run db:restore:prod -- --file db/DataBackup/gallery_dev_data_....txt` (type `yes`) +4. `npm run images:sync-to-prod` +5. **TrueNAS Web UI** → restart **gallery-web** (or `npm run docker:publish` if code changed) + +--- + +## Database scripts + +| Command | Where | Purpose | +|---------|-------|---------| +| pgAdmin + `split-dev-prod-pgadmin.sql` | Dev PC pgAdmin | One-time split (recommended) | +| `npm run db:split-databases` | Dev PC PowerShell | Same split (needs `PGUSER=postgres`) | +| `npm run db:sync-from-prod` | Dev PC PowerShell | Clone prod → dev | +| `npm run db:backup` | Dev PC PowerShell | Dev backup | +| `npm run db:restore:prod` | Dev PC PowerShell | Restore into prod | + +## Image sync + +| Command | Where | Direction | +|---------|-------|-----------| +| `npm run images:sync-to-prod` | Dev PC PowerShell | Dev → TrueNAS volume | +| `npm run images:sync-from-prod` | Dev PC PowerShell | TrueNAS → dev repo | + +## Safety guards + +- Prod restore reads only `infra/docker/.env.prod` +- Dev backup refuses `_prod` database names without `--prod` +- Destructive prod ops require typing `yes` or `CONFIRM_PROD=1` + +## Legacy deployment + +Node on dev PC `:3520` + nginx → Vite `:5173` is optional; see [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf). Production should use TrueNAS Docker on `:5173`. + +Deploy details: [`infra/docker/DEPLOY-truenas.md`](../infra/docker/DEPLOY-truenas.md). diff --git a/Documentation/setup.md b/Documentation/setup.md index f1bda1a..4e44a9e 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -20,10 +20,10 @@ cp .env.example .env | `DB_PORT` | Port (default `5432`) | | `DB_USER` | Database user | | `DB_PASSWORD` | Database password | -| `DB_NAME` | Database name (`Gallery`) | -| `PORT` | API listen port (default `3001`; production uses `3520`) | +| `DB_NAME` | Database name (`gallery_dev` for dev; prod uses `gallery_prod`) | +| `PORT` | API listen port (`3451` for `dev:web`; prod container uses `5173`) | | `HOST` | Bind address (default `0.0.0.0` — required for LAN access) | -| `PUBLIC_URL` | Optional public URL shown at startup (e.g. `http://gallery.mysuperlab.netcraze.pro`) | +| `PUBLIC_URL` | Public URL (dev: `https://devgallery.mysuperlab.netcraze.pro`; prod: `https://gallery.mysuperlab.netcraze.pro`) | | `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) | | `IMAGE_DIR` | Root for cached images (default `./data/images`) | @@ -89,14 +89,22 @@ Image fetch can take hours if you run it for the entire catalog. The first line | Command | Purpose | |---------|---------| +| `npm run dev:web` | **Public dev stack** — Vite `:5173`, API `:3451` (Keenetic → devgallery…) | +| `npm run docker:publish` | Build + push prod image to Gitea | +| `npm run images:sync-to-prod` | Copy `data/images/` → TrueNAS via SMB `Gallery` share | +| `npm run images:sync-from-prod` | Copy prod images → dev repo | +| `npm run db:sync-from-prod` | Clone `gallery_prod` → `gallery_dev` | +| `npm run db:backup` / `db:restore:prod` | Dev backup / promote to prod | | `npm run build` | Build production SPA into `client/dist` | | `npm run start` | API + static SPA on `HOST`:`PORT` | | `npm run start:prod` | Build client, then start server | | `npm run server` | Alias for `start` | -| `npm run dev:server` | API with nodemon reload | +| `npm run dev:server` | API with nodemon reload (local `:3520` / `:5173` workflow) | | `npm run dev:client` | Vite dev server on :5173 | | `npm run dev` | Alias for `start` | +See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. + **Production frontend:** build the client, then start the server: ```bash @@ -105,53 +113,30 @@ npm run build npm run start ``` -Open http://localhost:3520 (or your configured `HOST`/`PORT`). +Open http://localhost:5173 (Vite) or http://localhost:3451 (API only). ## Production deployment -This install is intended to run at: +Production runs as **`gallery-web`** on TrueNAS at **https://gallery.mysuperlab.netcraze.pro** (Keenetic → `:5173`). Full guide: [environments.md](environments.md) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md). | Access | URL | |--------|-----| -| Public (reverse proxy) | http://gallery.mysuperlab.netcraze.pro | -| LAN direct | http://192.168.10.70:3520 | +| Production (Keenetic) | https://gallery.mysuperlab.netcraze.pro | +| Development (Keenetic) | https://devgallery.mysuperlab.netcraze.pro | +| LAN direct (prod container) | http://192.168.10.122:5173 | +| LAN direct (dev PC) | http://192.168.10.70:5173 | -### 1. Configure `.env` +Quick deploy checklist: -Copy `.env.example` → `.env` and set at least: +1. One-time DB split in **pgAdmin** on dev PC: [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) +2. Dev `.env` → `DB_NAME=gallery_dev`, `PORT=3451`, `PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro` +3. `net use \\192.168.10.122\Gallery` → `npm run images:sync-to-prod` +4. `npm run docker:publish` → TrueNAS Custom App from `infra/docker/compose.truenas.yaml` +5. Keenetic: both domains → `:5173`, protocol to device **`http`**, correct IP per environment -```env -PORT=3520 -HOST=0.0.0.0 -PUBLIC_URL=http://gallery.mysuperlab.netcraze.pro -TRUST_PROXY=true -``` +### Legacy deployment (optional) -`HOST=0.0.0.0` lets the app accept connections on the machine’s LAN IP (`192.168.10.70`). The client uses relative `/api` and `/images` paths, so no frontend URL changes are needed. - -### 2. Build and start - -```bash -npm install -cd client && npm install && cd .. -npm run start:prod -``` - -Or use the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service) (adjust `User`, `WorkingDirectory`, and `EnvironmentFile`). - -### 3. Reverse proxy (public domain) - -Point `gallery.mysuperlab.netcraze.pro` at the host running the app. Example nginx config: [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf). - -**Development (default in repo):** nginx forwards to **Vite on `127.0.0.1:5173`**. Run both `npm run dev:server` (API on `3520`) and `npm run dev:client` (`5173`). Vite proxies `/api` and `/images` to the API. If either process stops, the public hostname may return **503** (reverse proxy cannot reach upstream). - -**Production (built SPA):** change nginx `proxy_pass` to `http://127.0.0.1:3520` after `npm run build` and `npm run start` — Node serves `client/dist` and the API on one port. Prefer the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service) so the process restarts automatically. - -Keep `TRUST_PROXY=true` in `.env` so Express sees the correct client IP and scheme. - -### 4. Firewall - -Allow inbound **TCP 3520** on the gallery host if clients reach it directly on the LAN (`192.168.10.70:3520`). The public hostname only needs **80/443** on the reverse-proxy host. +Node on the dev PC at `:3520` with nginx → Vite `:5173` is superseded by TrueNAS Docker prod and `npm run dev:web` for public dev. See [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf) and [`deploy/gallery.service`](../deploy/gallery.service) only if you need a local nginx/systemd setup. ## Maintenance scripts @@ -226,14 +211,16 @@ Gitea: [Danilka/Art-gallery](https://gitea.mysuperlab.netcraze.pro/Danilka/Art-g git clone https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery.git ``` -After clone: copy `.env.example` → `.env`, install dependencies, run `npm run setup` against your Postgres instance, then the post-seed steps above. +After clone: copy `.env.example` → `.env`, install dependencies, run [one-time DB split](Documentation/environments.md#step-b--split-the-database-pgadmin), then post-seed steps. Deploy: [environments.md](Documentation/environments.md). ## Troubleshooting | Symptom | Likely cause | Fix | |---------|--------------|-----| | Empty timeline | DB not seeded | `npm run seed` | -| 503 on public URL | Vite or API not running behind nginx | Start `npm run dev:server` + `npm run dev:client`, or switch nginx to production `:3520` | +| 502 / 504 on public URL | Keenetic rule wrong (IP, port, or `https` to device) | Dev → `192.168.10.70:5173`; prod → `192.168.10.122:5173`; protocol **`http`** — see [environments.md](environments.md) | +| 503 on public URL | Dev servers not running | `npm run dev:web` (or `dev:server` + `dev:client` for local HMR) | +| **`manifest unknown`** on TrueNAS deploy | Image not in Gitea | `npm run docker:publish` on dev PC first | | 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs | | “Biographical information not yet available” | Bios not fetched | `npm run fetch-artist-bios` | | Placeholder portraits on timeline | `portrait_path` not set | `npm run fetch-artist-images` | diff --git a/README.md b/README.md index 1ae17a5..3052f21 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- | Document | Purpose | |----------|---------| | [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow | +| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync | | [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts | | [Documentation/DB_structure.md](Documentation/DB_structure.md) | PostgreSQL tables | | [Documentation/API.md](Documentation/API.md) | REST endpoints | @@ -19,7 +20,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- ## Setup -1. Copy `.env.example` to `.env` and set database credentials. +1. Copy `.env.example` to `.env` and set database credentials (`DB_NAME=gallery_dev` after [one-time split](Documentation/environments.md)). 2. Install dependencies: ```bash @@ -52,25 +53,36 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order ``` -5. Build the client and start the server: +5. **Development (public URL):** ```bash - npm run start:prod + npm run dev:web ``` - **Production URLs:** - - Public: http://gallery.mysuperlab.netcraze.pro (via reverse proxy) - - LAN: http://192.168.10.70:3520 + Open https://devgallery.mysuperlab.netcraze.pro (or http://localhost:5173 locally). - See [Documentation/setup.md](Documentation/setup.md#production-deployment) for nginx/systemd configs in `deploy/`. + **Production:** https://gallery.mysuperlab.netcraze.pro — see [Documentation/environments.md](Documentation/environments.md) and [infra/docker/DEPLOY-truenas.md](infra/docker/DEPLOY-truenas.md). + +## Deployed URLs + +| Environment | URL | Host | +|-------------|-----|------| +| Development | https://devgallery.mysuperlab.netcraze.pro | Dev PC `:5173` (Vite) + `:3451` (API) | +| Production | https://gallery.mysuperlab.netcraze.pro | TrueNAS Docker `:5173` | + +Operator guide: [Documentation/environments.md](Documentation/environments.md). ## Development -Run API and Vite dev server separately: +**Public dev (Keenetic):** ```bash -npm run dev:server # API — PORT from .env (3520 or 3001) -npm run dev:client # http://localhost:5173 (proxies /api and /images) +npm run dev:web # Vite :5173, API :3451 ``` -Both processes must run when the public domain is proxied to Vite (`deploy/nginx-gallery.conf`). +**Local HMR:** + +```bash +npm run dev:server # API — PORT from .env +npm run dev:client # http://localhost:5173 (proxies /api and /images) +``` diff --git a/client/vite.config.ts b/client/vite.config.ts index 187b881..158f8f6 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig(({ mode }) => { plugins: [react()], server: { host: true, - allowedHosts: ['gallery.mysuperlab.netcraze.pro', 'localhost'], + allowedHosts: ['devgallery.mysuperlab.netcraze.pro', 'gallery.mysuperlab.netcraze.pro', 'localhost'], proxy: { '/api': apiTarget, '/images': apiTarget, diff --git a/db/split-dev-prod-pgadmin.sql b/db/split-dev-prod-pgadmin.sql new file mode 100644 index 0000000..d7bf0f6 --- /dev/null +++ b/db/split-dev-prod-pgadmin.sql @@ -0,0 +1,126 @@ +-- ============================================================================= +-- Gallery: one-time split Gallery → gallery_prod + gallery_dev +-- ============================================================================= +-- +-- WHERE TO RUN: pgAdmin on your **dev PC** (192.168.10.70) +-- NOT on TrueNAS shell — psql is not required on TrueNAS. +-- +-- BEFORE YOU START (dev PC, normal PowerShell in repo folder): +-- 1. Stop Gallery if running: close terminals with dev:web / dev:server / dev:client +-- 2. Stop the prod container on TrueNAS if it already exists (Apps → gallery-web → Stop) +-- +-- pgAdmin SETUP: +-- 1. Open pgAdmin +-- 2. Register server (if needed): +-- Host: 192.168.10.122 +-- Port: 5432 +-- Maintenance database: postgres +-- Username: postgres ← must be superuser, NOT gallery +-- Password: (your postgres password) +-- 3. In the tree: Servers → (your server) → Databases → **postgres** +-- 4. Right-click **postgres** → Query Tool +-- 5. File → Open → select this file (db/split-dev-prod-pgadmin.sql) +-- 6. Run in order: highlight STEP 0 → Execute (F5), then STEP 1, STEP 2, … +-- +-- IMPORTANT: Run each STEP block separately (highlight one STEP section, F5). +-- CREATE DATABASE (STEP 3) must NOT run inside a transaction with other steps. +-- +-- AFTER SUCCESS: +-- On dev PC (normal PowerShell, repo root): +-- npm run migrate +-- npm run dev:web +-- Open http://devgallery.mysuperlab.netcraze.pro +-- +-- ============================================================================= + + +-- ============================================================================= +-- STEP 0 — Pre-flight (read-only). Run this first. +-- Expected before split: one row with datname = Gallery +-- Expected after split: gallery_prod and gallery_dev +-- ============================================================================= + +SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size +FROM pg_database +WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev') +ORDER BY datname; + + +-- ============================================================================= +-- STEP 1 — Disconnect all clients from Gallery databases +-- Run on database: postgres +-- ============================================================================= + +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev') + AND pid <> pg_backend_pid(); + + +-- ============================================================================= +-- STEP 2 — Rename legacy database → gallery_prod +-- Run on database: postgres +-- Skip this step if STEP 0 already shows gallery_prod (split already done). +-- Run ONLY ONE of the two ALTER lines below (whichever matches STEP 0): +-- ============================================================================= + +-- Use this if STEP 0 showed datname = Gallery (capital G): +ALTER DATABASE "Gallery" RENAME TO gallery_prod; + +-- OR use this if STEP 0 showed datname = gallery (all lowercase) — comment out the line above: +-- ALTER DATABASE gallery RENAME TO gallery_prod; + + +-- ============================================================================= +-- STEP 3 — Clone gallery_dev from gallery_prod +-- Run on database: postgres +-- Skip if gallery_dev already exists in STEP 0. +-- Takes 1–2 minutes for ~1000 paintings. Do not run twice. +-- ============================================================================= + +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname = 'gallery_prod' + AND pid <> pg_backend_pid(); + +CREATE DATABASE gallery_dev WITH TEMPLATE gallery_prod; + + +-- ============================================================================= +-- STEP 4 — Grants for app user +-- Run on database: postgres +-- ============================================================================= + +GRANT ALL PRIVILEGES ON DATABASE gallery_dev TO gallery; +GRANT ALL PRIVILEGES ON DATABASE gallery_prod TO gallery; + + +-- ============================================================================= +-- STEP 5 — Verify databases (read-only) +-- Run on database: postgres +-- Expected: gallery_prod and gallery_dev (Gallery gone) +-- ============================================================================= + +SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size +FROM pg_database +WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev') +ORDER BY datname; + + +-- ============================================================================= +-- STEP 6 — Verify row count +-- +-- *** IMPORTANT: Query Tool must be connected to gallery_dev, NOT postgres *** +-- +-- In pgAdmin: +-- 1. Close this Query Tool tab (or ignore postgres connection) +-- 2. Tree: Servers → your server → Databases → **gallery_dev** +-- 3. Right-click **gallery_dev** → Query Tool ← new tab, new connection +-- 4. Paste and run ONLY the two lines below (F5) +-- +-- If current_database() is not gallery_dev, you will get "relation paintings does not exist" +-- ============================================================================= + +SELECT current_database() AS connected_to; -- must show: gallery_dev + +SELECT count(*) AS painting_count FROM paintings; -- expect ~1088 diff --git a/db/split-dev-prod.sql b/db/split-dev-prod.sql new file mode 100644 index 0000000..157544a --- /dev/null +++ b/db/split-dev-prod.sql @@ -0,0 +1,27 @@ +-- One-time dev/prod database split on TrueNAS PostgreSQL. +-- +-- Preferred: pgAdmin on dev PC — open db/split-dev-prod-pgadmin.sql (step-by-step). +-- Alternative (if psql is installed): psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql +-- +-- NOTE: CREATE DATABASE cannot run inside a DO block; this file uses standalone statements. + +-- Disconnect clients +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev') + AND pid <> pg_backend_pid(); + +-- Rename legacy → gallery_prod (run ONE of these; comment out the other) +ALTER DATABASE "Gallery" RENAME TO gallery_prod; +-- ALTER DATABASE gallery RENAME TO gallery_prod; + +-- Disconnect from prod before template clone +SELECT pg_terminate_backend(pid) +FROM pg_stat_activity +WHERE datname = 'gallery_prod' AND pid <> pg_backend_pid(); + +-- Clone dev from prod (skip if gallery_dev already exists) +CREATE DATABASE gallery_dev WITH TEMPLATE gallery_prod; + +GRANT ALL PRIVILEGES ON DATABASE gallery_dev TO gallery; +GRANT ALL PRIVILEGES ON DATABASE gallery_prod TO gallery; diff --git a/deploy/gallery.service b/deploy/gallery.service index 05618f8..ea35ec1 100644 --- a/deploy/gallery.service +++ b/deploy/gallery.service @@ -1,4 +1,5 @@ -# systemd unit — adjust User, WorkingDirectory, and EnvironmentFile paths. +# LEGACY — superseded by TrueNAS Docker prod (https://gallery.mysuperlab.netcraze.pro:5173). +# systemd unit for running Node directly on a Linux host — not used in current homelab setup. # Install: # sudo cp deploy/gallery.service /etc/systemd/system/gallery.service # sudo systemctl daemon-reload diff --git a/deploy/nginx-gallery.conf b/deploy/nginx-gallery.conf index 984ab74..67c82b0 100644 --- a/deploy/nginx-gallery.conf +++ b/deploy/nginx-gallery.conf @@ -1,5 +1,8 @@ +# LEGACY — optional nginx on dev PC. Production uses TrueNAS Docker on :5173 +# (see Documentation/environments.md and infra/docker/DEPLOY-truenas.md). +# # Reverse proxy for gallery.mysuperlab.netcraze.pro → Vite dev client on :5173. -# Vite proxies /api and /images to the Node API on :3520 (see client/vite.config.ts). +# Vite proxies /api and /images to the Node API on :3451 (dev:web) or :3520 (legacy local dev). # # Dev stack: npm run dev:server && npm run dev:client # Production (built SPA on Node only): point location / at http://127.0.0.1:3520 instead. diff --git a/infra/docker/.env.prod.example b/infra/docker/.env.prod.example new file mode 100644 index 0000000..6c70383 --- /dev/null +++ b/infra/docker/.env.prod.example @@ -0,0 +1,14 @@ +# Copy to infra/docker/.env.prod on TrueNAS or build PC (do not commit). +# Used by compose.prod.yaml and npm run db:migrate:prod / db:restore:prod. + +DB_HOST=192.168.10.122 +DB_PORT=5432 +DB_USER=gallery +DB_PASSWORD=YOUR_POSTGRES_PASSWORD +DB_NAME=gallery_prod + +PORT=5173 +HOST=0.0.0.0 +PUBLIC_URL=https://gallery.mysuperlab.netcraze.pro +TRUST_PROXY=true +IMAGE_DIR=/app/data/images diff --git a/infra/docker/DEPLOY-truenas.md b/infra/docker/DEPLOY-truenas.md new file mode 100644 index 0000000..3f50395 --- /dev/null +++ b/infra/docker/DEPLOY-truenas.md @@ -0,0 +1,149 @@ +# Deploy Gallery on TrueNAS Scale + +Production deployment for the **Express API + built Vite SPA** container. PostgreSQL stays on the host at `192.168.10.122:5432`. Public URL: **https://gallery.mysuperlab.netcraze.pro** (Keenetic → TrueNAS `:5173`, protocol to device **http**). + +See also [Documentation/environments.md](../../Documentation/environments.md). + +## Architecture + +```text +Browser (HTTPS) + → Keenetic (KeenDNS, SSL termination) + → gallery-web container on TrueNAS (:5173, HTTP) + → PostgreSQL (192.168.10.122:5432) → gallery_prod + → /mnt/BasePool/Applications/Gallery/data/images (volume) + → SMB share Gallery → \\192.168.10.122\Gallery (image sync from dev PC) +``` + +## Prerequisites + +| Item | Notes | +|------|-------| +| TrueNAS Scale 25.04+ | Apps → Custom App support | +| PostgreSQL | `gallery_prod` (dev: `gallery_dev` on same host) — split via [pgAdmin script](../../db/split-dev-prod-pgadmin.sql) | +| Gitea registry | `gitea.mysuperlab.netcraze.pro` — image **`danilka/gallery-web:latest`** pushed before deploy | +| SMB share | **`Gallery`** at `/mnt/BasePool/Applications/Gallery` for `images:sync-to-prod` | +| Keenetic | `gallery.mysuperlab.netcraze.pro` → `192.168.10.122:5173`, protocol **`http`**, Preserve Host ON | + +If deploy fails with **`manifest unknown`**, the image is not in Gitea yet — complete [§1 Build and push](#1-build-and-push-image-dev-machine) first. + +## 1. Build and push image (dev machine) + +**Where:** Dev PC — **PowerShell as Administrator** (LAN push hosts entry), **Docker Desktop running** + +```powershell +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +docker login gitea.mysuperlab.netcraze.pro +npm run docker:publish +``` + +Push only (already built): `npm run docker:publish:push-only` + +Image: `gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest` + +### Gitea registry tokens + +| Machine | Token scope | +|---------|-------------| +| Build PC | `write:package` | +| TrueNAS pull | `read:package` | + +Register on TrueNAS: **Apps → Configuration → Sign in to a Docker registry** — URL `https://gitea.mysuperlab.netcraze.pro`, username `danilka` (lowercase for registry). + +### Registry token URL fix + +If `docker push` fails with internal HTTP token URL, set Gitea `ROOT_URL` to `https://gitea.mysuperlab.netcraze.pro/` and restart Gitea. Full walkthrough: [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../../Drunkmeyou/Documentation/gitea-https-keenetic-npm-setup.md). + +### Offline fallback (no registry) + +On **dev PC**: `.\infra\docker\save-for-truenas.ps1` → copy `gallery-web.tar` via SMB `Gallery` share. + +On **TrueNAS shell**: `sudo bash truenas-load-image.sh /path/to/gallery-web.tar` + +In Custom App YAML: `pull_policy: if_not_present`, then redeploy. + +## 2. Prepare TrueNAS storage + +**Where:** TrueNAS — **Shell**, as **root** + +```bash +mkdir -p /mnt/BasePool/Applications/Gallery/data/images/portraits +mkdir -p /mnt/BasePool/Applications/Gallery/data/images/paintings/thumbs +chown -R 1001:1001 /mnt/BasePool/Applications/Gallery +chmod -R u+rwX,g+rwX /mnt/BasePool/Applications/Gallery +``` + +Enable SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` (for image sync from dev PC). + +## 3. Sync images (first deploy) + +**Where:** Dev PC — PowerShell (normal), repo root + +```powershell +net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER +cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery +npm run images:sync-to-prod +``` + +UNC destination: `\\192.168.10.122\Gallery\data\images` + +## 4. Install Custom App + +**Where:** TrueNAS — **Web UI** + +1. **Apps → Discover Apps → Custom App** +2. Paste [`compose.truenas.yaml`](compose.truenas.yaml) +3. Set `DB_PASSWORD` (replace `YOUR_POSTGRES_PASSWORD`) +4. Deploy → wait for **gallery-web** **Running** + +## 5. Keenetic (production) + +| Field | Value | +|-------|-------| +| Domain | `gallery.mysuperlab.netcraze.pro` | +| Upstream | `192.168.10.122:5173` | +| Protocol to device | **`http`** | +| Preserve Host | ON | + +Wrong IP or `https` to device → **502 / 504** from Keenetic (`Server: Web server`). + +## 6. Verify + +**Bypass Keenetic** (should be instant JSON): + +```powershell +curl.exe -s http://192.168.10.122:5173/api/bounds +``` + +**Public URL:** + +```powershell +curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds +``` + +Open **https://gallery.mysuperlab.netcraze.pro/** — timeline and `/images/paintings/...` should load. + +On TrueNAS shell: `bash infra/docker/truenas-verify.sh` + +## 7. Update production + +After code changes on dev PC: + +```powershell +npm run docker:publish +``` + +Restart **gallery-web** on TrueNAS (or rely on `pull_policy: always`). + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| **`manifest unknown`** | Run `npm run docker:publish` first; or offline `truenas-load-image.sh` | +| **502 / 504 public URL** | Keenetic → `192.168.10.122:5173`, protocol **`http`**; verify LAN curl above | +| Container cannot reach Postgres | `DB_HOST=192.168.10.122`; `extra_hosts` in compose | +| Empty timeline | `DB_NAME=gallery_prod`; run pgAdmin split if still on legacy `Gallery` | +| Missing images | `npm run images:sync-to-prod`; check volume mount and `chown 1001:1001` | +| Pull 401 | Gitea registry credentials on TrueNAS; `read:package` token | +| Push fails on dev PC | Gitea `ROOT_URL` HTTPS fix; use Admin PowerShell for LAN push | + \ No newline at end of file diff --git a/infra/docker/Dockerfile b/infra/docker/Dockerfile new file mode 100644 index 0000000..d88d2cd --- /dev/null +++ b/infra/docker/Dockerfile @@ -0,0 +1,46 @@ +# Production image for Gallery (Express API + built Vite SPA). +# Build from repository root: +# docker build -f infra/docker/Dockerfile -t gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest . + +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY client/package.json client/package-lock.json ./client/ +RUN npm ci --omit=dev +WORKDIR /app/client +RUN npm ci + +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +COPY client/package.json client/package-lock.json ./client/ +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/client/node_modules ./client/node_modules +COPY server ./server +COPY scripts ./scripts +COPY db ./db +COPY client ./client +RUN npm run build --prefix client + +FROM node:20-alpine AS runner +ENV NODE_ENV=production +ENV PORT=5173 +ENV HOST=0.0.0.0 +ENV IMAGE_DIR=/app/data/images + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs gallery + +WORKDIR /app +COPY package.json package-lock.json ./ +COPY --from=deps /app/node_modules ./node_modules +COPY server ./server +COPY scripts ./scripts +COPY db ./db +COPY --from=builder /app/client/dist ./client/dist +RUN mkdir -p /app/data/images && chown -R gallery:nodejs /app + +USER gallery +EXPOSE 5173 + +CMD ["node", "server/index.js"] diff --git a/infra/docker/build-push-lan.ps1 b/infra/docker/build-push-lan.ps1 new file mode 100644 index 0000000..a7b6f68 --- /dev/null +++ b/infra/docker/build-push-lan.ps1 @@ -0,0 +1,36 @@ +# Build and push the production web image to Gitea over LAN (fast path). +param( + [string]$Tag = "latest", + [switch]$SkipHosts, + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" +$Registry = "gitea.mysuperlab.netcraze.pro" +$Image = "$Registry/danilka/gallery-web" + +function Test-DockerRunning { + docker info 2>&1 | Out-Null + return $LASTEXITCODE -eq 0 +} + +Write-Host "=== Build + LAN push to Gitea ===" -ForegroundColor Cyan + +if (-not (Test-DockerRunning)) { + Write-Host "Docker is not running. Start Docker Desktop and retry." -ForegroundColor Red + exit 1 +} + +if (-not $SkipBuild) { + Write-Host "" + Write-Host "Building ${Image}:${Tag} ..." + docker build -f infra/docker/Dockerfile -t "${Image}:${Tag}" . + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host "Build complete." -ForegroundColor Green +} else { + Write-Host "Skipping build (-SkipBuild)." -ForegroundColor Yellow +} + +Write-Host "" +& "$PSScriptRoot/push-lan.ps1" -Tag $Tag -SkipHosts:$SkipHosts +exit $LASTEXITCODE diff --git a/infra/docker/compose.prod.yaml b/infra/docker/compose.prod.yaml new file mode 100644 index 0000000..b08b34f --- /dev/null +++ b/infra/docker/compose.prod.yaml @@ -0,0 +1,19 @@ +# Manual docker compose on a host with infra/docker/.env.prod present. +# Prefer compose.truenas.yaml for TrueNAS Custom App UI. + +services: + web: + build: + context: ../.. + dockerfile: infra/docker/Dockerfile + image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest + container_name: gallery-web + restart: unless-stopped + ports: + - "5173:5173" + env_file: + - .env.prod + volumes: + - /mnt/BasePool/Applications/Gallery/data/images:/app/data/images + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/infra/docker/compose.truenas.yaml b/infra/docker/compose.truenas.yaml new file mode 100644 index 0000000..d8405fb --- /dev/null +++ b/infra/docker/compose.truenas.yaml @@ -0,0 +1,34 @@ +# Paste into TrueNAS: Apps → Custom App → Install via Docker Compose +# +# BEFORE deploy: +# 1. npm run docker:publish on dev PC (image must exist in Gitea) +# 2. Replace YOUR_POSTGRES_PASSWORD below +# 3. Gitea pull credentials on TrueNAS (read:package token) +# 4. mkdir + chown image dir; npm run images:sync-to-prod +# 5. Keenetic: gallery.mysuperlab.netcraze.pro → 192.168.10.122:5173, protocol http +# +# See infra/docker/DEPLOY-truenas.md and Documentation/environments.md +services: + web: + image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest + container_name: gallery-web + restart: unless-stopped + pull_policy: always + ports: + - "5173:5173" + environment: + NODE_ENV: production + PORT: "5173" + HOST: "0.0.0.0" + DB_HOST: "192.168.10.122" + DB_PORT: "5432" + DB_USER: gallery + DB_PASSWORD: gallery + DB_NAME: gallery_prod + PUBLIC_URL: https://gallery.mysuperlab.netcraze.pro + TRUST_PROXY: "true" + IMAGE_DIR: /app/data/images + volumes: + - /mnt/BasePool/Applications/Gallery/data/images:/app/data/images + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/infra/docker/push-lan.ps1 b/infra/docker/push-lan.ps1 new file mode 100644 index 0000000..6cf0c44 --- /dev/null +++ b/infra/docker/push-lan.ps1 @@ -0,0 +1,64 @@ +# Push Docker image to Gitea over LAN (avoids KeenDNS hairpin slow upload). +param( + [string]$Tag = "latest", + [switch]$SkipHosts +) + +$ErrorActionPreference = "Stop" +$Registry = "gitea.mysuperlab.netcraze.pro" +$LanIp = "192.168.10.122" +$Image = "$Registry/danilka/gallery-web" +$HostsPath = "$env:SystemRoot\System32\drivers\etc\hosts" +$HostsMarker = "# gallery-gitea-lan-push" + +function Test-Admin { + $current = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + return $current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-GiteaDnsAddress { + try { + return [System.Net.Dns]::GetHostAddresses($Registry) | + Where-Object { $_.AddressFamily -eq 'InterNetwork' } | + Select-Object -First 1 -ExpandProperty IPAddressToString + } catch { + return $null + } +} + +Write-Host "=== Gitea LAN push ===" -ForegroundColor Cyan +$resolved = Get-GiteaDnsAddress +Write-Host "DNS resolves $Registry -> $resolved" +if ($resolved -and $resolved -ne $LanIp) { + Write-Host "Public/hairpin path detected (slow push). LAN override recommended." -ForegroundColor Yellow +} + +if (-not $SkipHosts) { + $hostsContent = Get-Content $HostsPath -Raw -ErrorAction SilentlyContinue + $hasEntry = $hostsContent -match "gitea\.mysuperlab\.netcraze\.pro" + if (-not $hasEntry) { + if (-not (Test-Admin)) { + Write-Host "" + Write-Host "Re-run as Administrator to add hosts entry, or add manually:" -ForegroundColor Yellow + Write-Host " $LanIp $Registry" + Write-Host " Then: ipconfig /flushdns" + exit 1 + } + Add-Content -Path $HostsPath -Value "`n$HostsMarker`n$LanIp $Registry" -Encoding ASCII + ipconfig /flushdns | Out-Null + Write-Host "Added hosts: $LanIp -> $Registry" -ForegroundColor Green + } else { + Write-Host "Hosts entry for gitea already present." -ForegroundColor Green + } +} + +$after = Get-GiteaDnsAddress +Write-Host "After override, resolves to: $after" + +Write-Host "" +Write-Host "Pushing ${Image}:${Tag} ..." +docker push "${Image}:${Tag}" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "" +Write-Host "Done. Remove hosts entry ($HostsMarker) when finished if you no longer need LAN override." -ForegroundColor Cyan diff --git a/infra/docker/save-for-truenas.ps1 b/infra/docker/save-for-truenas.ps1 new file mode 100644 index 0000000..fc87987 --- /dev/null +++ b/infra/docker/save-for-truenas.ps1 @@ -0,0 +1,17 @@ +# Save image to tar for fast SMB copy to TrueNAS (offline registry bypass). +param( + [string]$Tag = "latest", + [string]$OutFile = "gallery-web.tar" +) + +$ErrorActionPreference = "Stop" +$Image = "gitea.mysuperlab.netcraze.pro/danilka/gallery-web:${Tag}" + +Write-Host "Saving $Image to $OutFile ..." +docker save -o $OutFile $Image +$sizeMb = [math]::Round((Get-Item $OutFile).Length / 1MB, 1) +Write-Host "Saved $OutFile ($sizeMb MB)" +Write-Host "" +Write-Host "On TrueNAS shell:" +Write-Host " sudo docker load -i /path/to/$OutFile" +Write-Host " sudo docker tag gitea.mysuperlab.netcraze.pro/danilka/gallery-web:$Tag gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest" diff --git a/infra/docker/truenas-load-image.sh b/infra/docker/truenas-load-image.sh new file mode 100644 index 0000000..53380fd --- /dev/null +++ b/infra/docker/truenas-load-image.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Load a docker save tarball on TrueNAS and tag for the Gallery Custom App. +set -euo pipefail + +TAR_PATH="${1:-}" +TAG="${2:-latest}" +IMAGE="gitea.mysuperlab.netcraze.pro/danilka/gallery-web" + +if [[ -z "$TAR_PATH" || ! -f "$TAR_PATH" ]]; then + echo "Usage: sudo bash truenas-load-image.sh /path/to/gallery-web.tar [tag]" + exit 1 +fi + +docker load -i "$TAR_PATH" +docker tag "${IMAGE}:${TAG}" "${IMAGE}:latest" +docker images "${IMAGE}" +echo "Set Custom App pull_policy to IfNotPresent, then restart gallery-web." diff --git a/infra/docker/truenas-setup.sh b/infra/docker/truenas-setup.sh new file mode 100644 index 0000000..e4314ed --- /dev/null +++ b/infra/docker/truenas-setup.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Run on TrueNAS shell once before first deploy. +set -euo pipefail + +IMAGE_ROOT="/mnt/BasePool/Applications/Gallery/data/images" + +mkdir -p "${IMAGE_ROOT}/portraits" "${IMAGE_ROOT}/paintings/thumbs" +chown -R 1001:1001 "/mnt/BasePool/Applications/Gallery" +chmod -R u+rwX,g+rwX "/mnt/BasePool/Applications/Gallery" + +echo "Created ${IMAGE_ROOT} (owner uid 1001 = gallery user in container)" diff --git a/infra/docker/truenas-verify.sh b/infra/docker/truenas-verify.sh new file mode 100644 index 0000000..0067ba1 --- /dev/null +++ b/infra/docker/truenas-verify.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Post-deploy checks — run on TrueNAS shell after Custom App is running. +set -euo pipefail + +PUBLIC_URL="${PUBLIC_URL:-http://gallery.mysuperlab.netcraze.pro}" +LOCAL_URL="${LOCAL_URL:-http://127.0.0.1:5173}" + +echo "== Local container ==" +curl -sf -o /dev/null -w "HTTP %{http_code}\n" "${LOCAL_URL}/" || echo "FAIL: container not reachable on :5173" + +echo "== API bounds ==" +curl -sf "${LOCAL_URL}/api/bounds" || echo "FAIL: /api/bounds" + +echo "== Public URL ==" +curl -sf -o /dev/null -w "HTTP %{http_code}\n" "${PUBLIC_URL}/" || echo "FAIL: public URL not reachable" + +echo "== Container status ==" +docker ps --filter name=gallery-web --format '{{.Names}} {{.Status}}' || true + +echo "== Image volume ==" +ls -la /mnt/BasePool/Applications/Gallery/data/images/paintings 2>/dev/null | head -5 || echo "WARN: paintings dir missing" + +echo "Done. Open ${PUBLIC_URL}/ in a browser." diff --git a/infra/scripts/sync-images-from-prod.ps1 b/infra/scripts/sync-images-from-prod.ps1 new file mode 100644 index 0000000..3a2deea --- /dev/null +++ b/infra/scripts/sync-images-from-prod.ps1 @@ -0,0 +1,42 @@ +# Sync production image files from TrueNAS to dev repo. +# +# Usage: +# npm run images:sync-from-prod +# .\infra\scripts\sync-images-from-prod.ps1 + +param( + [string]$Source = "\\192.168.10.122\Gallery\data\images", + [string]$Dest = (Join-Path $PSScriptRoot "..\..\data\images"), + [switch]$SkipConfirm +) + +$ErrorActionPreference = "Stop" +if (-not (Test-Path $Dest)) { + New-Item -ItemType Directory -Path $Dest -Force | Out-Null +} +$Dest = (Resolve-Path $Dest).Path + +Write-Host "Source: $Source" +Write-Host "Dest: $Dest" + +if (-not $SkipConfirm) { + $confirm = Read-Host "Copy all files from prod (skip older)? Type yes" + if ($confirm -ne "yes") { + Write-Host "Aborted." + exit 0 + } +} + +if (-not (Test-Path $Source)) { + Write-Error "Source not found: $Source" + exit 1 +} + +robocopy $Source $Dest /E /XO /R:2 /W:3 /NFL /NDL /NJH /NJS +$code = $LASTEXITCODE +if ($code -ge 8) { + Write-Error "robocopy failed with exit code $code" + exit $code +} + +Write-Host "Image sync complete (robocopy exit $code)." diff --git a/infra/scripts/sync-images-to-prod.ps1 b/infra/scripts/sync-images-to-prod.ps1 new file mode 100644 index 0000000..a1fd36c --- /dev/null +++ b/infra/scripts/sync-images-to-prod.ps1 @@ -0,0 +1,49 @@ +# Sync dev image files to TrueNAS production volume. +# +# Usage (from repo root): +# npm run images:sync-to-prod +# Copies data/images/ → \\192.168.10.122\Gallery\data\images +# Map the share first if needed: net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER + +param( + [string]$Source = (Join-Path $PSScriptRoot "..\..\data\images"), + [string]$Dest = "\\192.168.10.122\Gallery\data\images", + [switch]$SkipConfirm +) + +$ErrorActionPreference = "Stop" +$Source = (Resolve-Path $Source -ErrorAction Stop).Path + +Write-Host "Source: $Source" +Write-Host "Dest: $Dest" + +if (-not $SkipConfirm) { + $confirm = Read-Host "Copy all files (skip older)? Type yes" + if ($confirm -ne "yes") { + Write-Host "Aborted." + exit 0 + } +} + +$smbRoot = "\\192.168.10.122\Gallery" +if ($env:SMB_USER -and $env:SMB_PASSWORD) { + Write-Host "Mapping $sbmRoot ..." + net use $sbmRoot /user:$env:SMB_USER $env:SMB_PASSWORD 2>&1 | Out-Host +} + +try { + if (-not (Test-Path $Dest)) { + New-Item -ItemType Directory -Path $Dest -Force | Out-Null + } +} catch { + Write-Host "Note: could not pre-create dest (will rely on robocopy): $($_.Exception.Message)" +} + +robocopy $Source $Dest /E /XO /R:2 /W:3 /NFL /NDL /NJH /NJS +$code = $LASTEXITCODE +if ($code -ge 8) { + Write-Error "robocopy failed with exit code $code" + exit $code +} + +Write-Host "Image sync complete (robocopy exit $code)." diff --git a/package.json b/package.json index b0cab7b..9adfd4d 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,19 @@ "start": "node server/index.js", "start:prod": "npm run build && node server/index.js", "server": "node server/index.js", + "dev:web": "node scripts/dev-web.js", "dev:server": "nodemon server/index.js", "dev:client": "npm run dev --prefix client", + "docker:publish": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/docker/build-push-lan.ps1", + "docker:publish:push-only": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/docker/build-push-lan.ps1 -SkipBuild", + "db:split-databases": "node scripts/split-dev-prod-databases.js", + "db:sync-from-prod": "node scripts/sync-prod-to-dev.js", + "db:backup": "node scripts/backup-db-data.js", + "db:backup:prod": "node scripts/backup-db-data.js --prod", + "db:restore": "node scripts/restore-db-data.js", + "db:restore:prod": "node scripts/restore-db-data.js --prod", + "images:sync-to-prod": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/sync-images-to-prod.ps1", + "images:sync-from-prod": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/sync-images-from-prod.ps1", "dev": "node server/index.js", "setup": "node server/migrate.js && node scripts/seed-wikipedia.js" }, diff --git a/scripts/backup-db-data.js b/scripts/backup-db-data.js new file mode 100644 index 0000000..05cbea3 --- /dev/null +++ b/scripts/backup-db-data.js @@ -0,0 +1,135 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const pg = require('pg'); +const { + assertProdDatabase, + assertDevDatabase, + loadDevPgConfig, + loadProdPgConfig, + rootDir, +} = require('./db-env'); + +const { Client } = pg; + +const backupDir = path.join(rootDir, 'db', 'DataBackup'); +const fromProd = process.argv.includes('--prod'); +const config = fromProd ? loadProdPgConfig() : loadDevPgConfig(); + +if (fromProd) { + assertProdDatabase(config.database); +} else { + assertDevDatabase(config.database); + if (config.database.endsWith('_prod')) { + console.error('Refusing backup of production database without --prod. Use: npm run db:backup:prod'); + process.exit(1); + } +} + +function sqlLiteral(value) { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + if (value instanceof Date) return `'${value.toISOString()}'`; + if (typeof value === 'number' || typeof value === 'bigint') return String(value); + if (typeof value === 'object') { + return `'${JSON.stringify(value).replace(/'/g, "''")}'`; + } + return `'${String(value).replace(/'/g, "''")}'`; +} + +function timestampSlug(date = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + '_', + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(''); +} + +async function main() { + fs.mkdirSync(backupDir, { recursive: true }); + + const client = new Client(config); + await client.connect(); + + const dbNameRes = await client.query('SELECT current_database() AS name'); + const dbName = dbNameRes.rows[0].name; + + const tablesRes = await client.query( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name`, + ); + + const lines = [ + '-- Gallery PostgreSQL data backup', + `-- Generated: ${new Date().toISOString()}`, + `-- Database: ${dbName}`, + '-- Format: INSERT statements (data only)', + '', + ]; + + let totalRows = 0; + + for (const { table_name: tableName } of tablesRes.rows) { + const colsRes = await client.query( + `SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position`, + [tableName], + ); + const columns = colsRes.rows.map((row) => row.column_name); + const dataRes = await client.query(`SELECT * FROM "${tableName}"`); + const rowCount = dataRes.rowCount ?? 0; + totalRows += rowCount; + + lines.push(`-- TABLE ${tableName} (${rowCount} rows)`); + + if (rowCount === 0) { + lines.push(''); + continue; + } + + const quotedCols = columns.map((c) => `"${c}"`).join(', '); + for (const row of dataRes.rows) { + const values = columns.map((col) => sqlLiteral(row[col])).join(', '); + lines.push(`INSERT INTO "${tableName}" (${quotedCols}) VALUES (${values});`); + } + lines.push(''); + } + + await client.end(); + + const slug = timestampSlug(); + const txtName = `${dbName}_data_${slug}.txt`; + const zipName = `${dbName}_data_${slug}.zip`; + const txtPath = path.join(backupDir, txtName); + const zipPath = path.join(backupDir, zipName); + + fs.writeFileSync(txtPath, lines.join('\n'), 'utf8'); + + execFileSync( + 'powershell', + [ + '-NoProfile', + '-Command', + `Compress-Archive -LiteralPath '${txtPath.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`, + ], + { stdio: 'inherit' }, + ); + + console.log(`Backup written: ${txtPath}`); + console.log(`Archive written: ${zipPath}`); + console.log(`Tables: ${tablesRes.rowCount}, rows: ${totalRows}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/db-env.js b/scripts/db-env.js new file mode 100644 index 0000000..c7e93d6 --- /dev/null +++ b/scripts/db-env.js @@ -0,0 +1,103 @@ +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const rootDir = path.resolve(__dirname, '..'); + +const PROD_DB_SUFFIX = '_prod'; +const DEV_DB_NAME = 'gallery_dev'; +const PROD_DB_NAME = 'gallery_prod'; +const LEGACY_DB_NAMES = ['Gallery', 'gallery']; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + const content = fs.readFileSync(filePath, 'utf8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) + || (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = value; + } +} + +function pgConfigFromEnv() { + const host = process.env.DB_HOST || '192.168.10.122'; + const port = Number(process.env.DB_PORT || 5432); + const user = process.env.DB_USER; + const password = process.env.DB_PASSWORD; + const database = process.env.DB_NAME; + if (!user || !password || !database) { + throw new Error('DB_USER, DB_PASSWORD, and DB_NAME are required'); + } + return { host, port, user, password, database }; +} + +function assertProdDatabase(dbName) { + if (!dbName.endsWith(PROD_DB_SUFFIX) && dbName !== PROD_DB_NAME) { + throw new Error( + `Refusing prod operation on database "${dbName}". ` + + `Target must be "${PROD_DB_NAME}" or end with "${PROD_DB_SUFFIX}".`, + ); + } + return dbName; +} + +function assertDevDatabase(dbName) { + if (dbName === PROD_DB_NAME || dbName.endsWith(PROD_DB_SUFFIX)) { + throw new Error( + `Refusing dev operation on production database "${dbName}". ` + + `Use "${DEV_DB_NAME}" for development.`, + ); + } + return dbName; +} + +function loadDevPgConfig() { + loadEnvFile(path.join(rootDir, '.env')); + const config = pgConfigFromEnv(); + assertDevDatabase(config.database); + return config; +} + +function loadProdPgConfig() { + loadEnvFile(path.join(rootDir, 'infra', 'docker', '.env.prod')); + const config = pgConfigFromEnv(); + assertProdDatabase(config.database); + return config; +} + +async function confirmProdAction(message) { + if (process.env.CONFIRM_PROD === '1') return; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question(`${message}\nType "yes" to continue: `, resolve); + }); + rl.close(); + if (answer.trim().toLowerCase() !== 'yes') { + throw new Error('Aborted.'); + } +} + +module.exports = { + PROD_DB_SUFFIX, + DEV_DB_NAME, + PROD_DB_NAME, + LEGACY_DB_NAMES, + rootDir, + loadEnvFile, + pgConfigFromEnv, + assertProdDatabase, + assertDevDatabase, + loadDevPgConfig, + loadProdPgConfig, + confirmProdAction, +}; diff --git a/scripts/dev-web.js b/scripts/dev-web.js new file mode 100644 index 0000000..33d0cc1 --- /dev/null +++ b/scripts/dev-web.js @@ -0,0 +1,43 @@ +/** + * Start public dev stack: Vite on :5173, API on :3451 (or PORT from .env). + * Keenetic: devgallery.mysuperlab.netcraze.pro → 192.168.10.70:5173 + */ +const { spawn } = require('child_process'); +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const apiPort = process.env.DEV_API_PORT || process.env.PORT || '3451'; +const vitePort = process.env.DEV_WEB_PORT || '5173'; + +const env = { ...process.env, PORT: apiPort }; + +function run(label, command, args, cwd) { + const child = spawn(command, args, { + cwd, + env, + shell: true, + stdio: 'inherit', + }); + child.on('exit', (code) => { + if (code && code !== 0) { + console.error(`${label} exited with code ${code}`); + } + }); + return child; +} + +console.log(`Gallery dev:web — UI http://0.0.0.0:${vitePort} API http://127.0.0.1:${apiPort}`); +console.log(`Set PUBLIC_URL=http://devgallery.mysuperlab.netcraze.pro in .env for Keenetic`); + +const api = run('API', 'npx', ['nodemon', 'server/index.js'], path.join(__dirname, '..')); +const client = run('Vite', 'npm', ['run', 'dev', '--prefix', 'client', '--', '--port', vitePort, '--host'], path.join(__dirname, '..')); + +function shutdown() { + api.kill('SIGTERM'); + client.kill('SIGTERM'); + process.exit(0); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/scripts/restore-db-data.js b/scripts/restore-db-data.js new file mode 100644 index 0000000..e7a23af --- /dev/null +++ b/scripts/restore-db-data.js @@ -0,0 +1,104 @@ +/** + * Restore a data-only backup (INSERT dumps from npm run db:backup) into a database. + * + * Dev: node scripts/restore-db-data.js --file db/DataBackup/gallery_dev_data_....txt + * Prod: npm run db:restore:prod -- --file db/DataBackup/gallery_dev_data_....txt + */ +const fs = require('fs'); +const path = require('path'); +const pg = require('pg'); +const { + assertProdDatabase, + assertDevDatabase, + confirmProdAction, + loadDevPgConfig, + loadProdPgConfig, +} = require('./db-env'); + +const { Client } = pg; + +function parseArgs(argv) { + const fileIdx = argv.indexOf('--file'); + if (fileIdx === -1 || !argv[fileIdx + 1]) { + throw new Error('--file is required'); + } + return { + filePath: path.resolve(argv[fileIdx + 1]), + prod: argv.includes('--prod'), + }; +} + +function extractInsertStatements(content) { + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('INSERT INTO ')); +} + +async function truncatePublicTables(client) { + const tablesRes = await client.query( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name`, + ); + const names = tablesRes.rows.map((r) => `"${r.table_name}"`).join(', '); + if (!names) return; + await client.query(`TRUNCATE TABLE ${names} RESTART IDENTITY CASCADE`); +} + +async function main() { + const { filePath, prod } = parseArgs(process.argv.slice(2)); + + if (!fs.existsSync(filePath)) { + throw new Error(`Backup file not found: ${filePath}`); + } + + const config = prod ? loadProdPgConfig() : loadDevPgConfig(); + const dbName = config.database; + const inserts = extractInsertStatements(fs.readFileSync(filePath, 'utf8')); + + if (inserts.length === 0) { + throw new Error('No INSERT statements found in backup file'); + } + + const prompt = prod + ? `RESTORE ${inserts.length} rows into PRODUCTION "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.` + : `Restore ${inserts.length} rows into "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.`; + + if (prod) { + assertProdDatabase(dbName); + } else { + assertDevDatabase(dbName); + } + await confirmProdAction(prompt); + + const client = new Client(config); + await client.connect(); + + console.log(`Truncating public tables in "${dbName}"...`); + await truncatePublicTables(client); + + console.log(`Restoring ${inserts.length} INSERT statements...`); + await client.query('SET session_replication_role = replica'); + let restored = 0; + try { + for (const statement of inserts) { + await client.query(statement); + restored += 1; + if (restored % 500 === 0) { + console.log(` ${restored}/${inserts.length}`); + } + } + } finally { + await client.query('SET session_replication_role = DEFAULT'); + } + + await client.end(); + console.log(`Restore complete: ${restored} statements into "${dbName}".`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/split-dev-prod-databases.js b/scripts/split-dev-prod-databases.js new file mode 100644 index 0000000..23f9554 --- /dev/null +++ b/scripts/split-dev-prod-databases.js @@ -0,0 +1,143 @@ +/** + * One-time split of legacy Gallery → gallery_prod + gallery_dev on TrueNAS PostgreSQL. + * + * Usage: + * PGPASSWORD=... PGHOST=192.168.10.122 PGUSER=postgres npm run db:split-databases + * + * Options: + * --copy pg_dump legacy → gallery_prod instead of rename + * --skip-dev Do not create gallery_dev + */ +const { execFileSync } = require('child_process'); +const pg = require('pg'); +const { + DEV_DB_NAME, + LEGACY_DB_NAMES, + PROD_DB_NAME, +} = require('./db-env'); + +const { Client } = pg; + +const args = new Set(process.argv.slice(2)); +const useCopy = args.has('--copy'); +const skipDev = args.has('--skip-dev'); + +const host = process.env.PGHOST ?? process.env.DB_HOST ?? '192.168.10.122'; +const port = Number(process.env.PGPORT ?? process.env.DB_PORT ?? 5432); +const user = process.env.PGUSER ?? process.env.DB_USER ?? 'postgres'; +const password = process.env.PGPASSWORD ?? process.env.DB_PASSWORD; + +if (!password) { + console.error('PGPASSWORD (or DB_PASSWORD) is required'); + process.exit(1); +} + +function quoteIdent(ident) { + return `"${ident.replace(/"/g, '""')}"`; +} + +async function databaseExists(client, name) { + const res = await client.query( + 'SELECT 1 FROM pg_database WHERE datname = $1', + [name], + ); + return res.rowCount > 0; +} + +async function findLegacyName(client) { + for (const name of LEGACY_DB_NAMES) { + if (await databaseExists(client, name)) return name; + } + return null; +} + +async function createDatabaseIfMissing(admin, name) { + if (await databaseExists(admin, name)) { + console.log(`Database "${name}" already exists`); + return false; + } + await admin.query(`CREATE DATABASE ${quoteIdent(name)}`); + console.log(`Created database "${name}"`); + return true; +} + +async function terminateConnections(admin, dbName) { + await admin.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [dbName], + ); +} + +async function cloneDevFromProd(admin) { + if (await databaseExists(admin, DEV_DB_NAME)) { + console.log(`"${DEV_DB_NAME}" already exists — skipping dev clone`); + return; + } + + await terminateConnections(admin, PROD_DB_NAME); + console.log(`Creating "${DEV_DB_NAME}" from TEMPLATE "${PROD_DB_NAME}"...`); + await admin.query( + `CREATE DATABASE ${quoteIdent(DEV_DB_NAME)} WITH TEMPLATE ${quoteIdent(PROD_DB_NAME)} OWNER ${quoteIdent(user)}`, + ); + console.log(`Created "${DEV_DB_NAME}" as a full copy of "${PROD_DB_NAME}"`); +} + +async function main() { + const admin = new Client({ host, port, user, password, database: 'postgres' }); + await admin.connect(); + + const legacyName = await findLegacyName(admin); + const hasProd = await databaseExists(admin, PROD_DB_NAME); + + if (hasProd) { + console.log(`"${PROD_DB_NAME}" already exists — skipping prod migration`); + } else if (legacyName) { + if (useCopy) { + await createDatabaseIfMissing(admin, PROD_DB_NAME); + console.log(`Copying ${legacyName} → ${PROD_DB_NAME} via pg_dump...`); + const dump = execFileSync( + 'pg_dump', + ['-h', host, '-p', String(port), '-U', user, '-d', legacyName, '--no-owner', '--no-acl'], + { env: { ...process.env, PGPASSWORD: password }, encoding: 'buffer' }, + ); + execFileSync( + 'psql', + ['-h', host, '-p', String(port), '-U', user, '-d', PROD_DB_NAME, '-v', 'ON_ERROR_STOP=1'], + { env: { ...process.env, PGPASSWORD: password }, input: dump, stdio: ['pipe', 'inherit', 'inherit'] }, + ); + console.log(`Copied ${legacyName} → ${PROD_DB_NAME}`); + } else { + await terminateConnections(admin, legacyName); + await admin.query(`ALTER DATABASE ${quoteIdent(legacyName)} RENAME TO ${quoteIdent(PROD_DB_NAME)}`); + console.log(`Renamed ${legacyName} → ${PROD_DB_NAME}`); + } + } else { + console.warn(`No legacy database (${LEGACY_DB_NAMES.join(' / ')}) or "${PROD_DB_NAME}" found`); + await createDatabaseIfMissing(admin, PROD_DB_NAME); + console.log('Run npm run migrate against gallery_prod to apply schema'); + } + + if (!skipDev) { + await cloneDevFromProd(admin); + console.log(`Next on dev PC: set .env DB_NAME=${DEV_DB_NAME}, then npm run migrate`); + } + + await admin.end(); + console.log('Done. TrueNAS compose.truenas.yaml should use DB_NAME=gallery_prod.'); +} + +main().catch((error) => { + if (error.code === '42501') { + console.error( + 'Permission denied — the gallery user cannot rename databases.\n' + + 'Use pgAdmin on the dev PC (recommended):\n' + + ' Open db/split-dev-prod-pgadmin.sql → connect as postgres → run each STEP (F5)\n' + + 'Or: PGUSER=postgres PGPASSWORD=... npm run db:split-databases\n' + + 'Or: psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql', + ); + } + console.error(error.message || error); + process.exit(1); +}); diff --git a/scripts/sync-prod-to-dev.js b/scripts/sync-prod-to-dev.js new file mode 100644 index 0000000..54b3d9f --- /dev/null +++ b/scripts/sync-prod-to-dev.js @@ -0,0 +1,81 @@ +/** + * Clone gallery_prod → gallery_dev (PostgreSQL TEMPLATE). + * Requires superuser or CREATEDB on the Postgres host. + */ +const path = require('path'); +const { execFileSync } = require('child_process'); +const pg = require('pg'); +const { + DEV_DB_NAME, + PROD_DB_NAME, + loadProdPgConfig, + rootDir, +} = require('./db-env'); + +const { Client } = pg; + +function quoteIdent(ident) { + return `"${ident.replace(/"/g, '""')}"`; +} + +async function main() { + const prodConfig = loadProdPgConfig(); + const { host, port, user, password } = prodConfig; + + const admin = new Client({ host, port, user, password, database: 'postgres' }); + await admin.connect(); + + console.log(`Terminating connections to "${DEV_DB_NAME}"...`); + await admin.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [DEV_DB_NAME], + ); + + const exists = await admin.query( + 'SELECT 1 FROM pg_database WHERE datname = $1', + [DEV_DB_NAME], + ); + + if (exists.rowCount > 0) { + console.log(`Dropping "${DEV_DB_NAME}"...`); + await admin.query(`DROP DATABASE ${quoteIdent(DEV_DB_NAME)}`); + } + + console.log(`Terminating connections to "${PROD_DB_NAME}"...`); + await admin.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [PROD_DB_NAME], + ); + + console.log(`Cloning "${PROD_DB_NAME}" → "${DEV_DB_NAME}" (TEMPLATE)...`); + await admin.query( + `CREATE DATABASE ${quoteIdent(DEV_DB_NAME)} WITH TEMPLATE ${quoteIdent(PROD_DB_NAME)} OWNER ${quoteIdent(user)}`, + ); + + await admin.end(); + + const verify = new Client({ host, port, user, password, database: DEV_DB_NAME }); + await verify.connect(); + const tables = await verify.query( + `SELECT count(*)::int AS n FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`, + ); + const paintings = await verify.query('SELECT count(*)::int AS n FROM paintings'); + await verify.end(); + + console.log(`Done. "${DEV_DB_NAME}" is a copy of "${PROD_DB_NAME}" (${tables.rows[0].n} tables, ${paintings.rows[0].n} paintings).`); + console.log('Syncing image files from production...'); + execFileSync('powershell', ['-NoProfile', '-File', path.join(rootDir, 'infra', 'scripts', 'sync-images-from-prod.ps1'), '-SkipConfirm'], { + cwd: rootDir, + stdio: 'inherit', + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});