From 9da065acbe0a5f0f4cadad73acff2002ebd0315b Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Mon, 6 Jul 2026 00:17:01 +0300 Subject: [PATCH] Add curator authentication with audit logging and fix empty 3D gallery sessions. Introduce session-based curator login, gate debug/checkup routes, log mutations to curator_audit_log, and keep guest hall preload public. Fix gallery view mounting so WebGL halls render reliably after navigation. --- .env.example | 6 + Documentation/API.md | 71 +++++- Documentation/DB_structure.md | 45 ++++ Documentation/FAC.md | 44 +++- Documentation/basics.md | 36 +++- Documentation/data-and-images.md | 4 +- Documentation/environments.md | 18 +- Documentation/setup.md | 30 ++- README.md | 4 +- client/src/api/client.ts | 55 ++++- client/src/components/CuratorLoginModal.css | 132 ++++++++++++ client/src/components/CuratorLoginModal.tsx | 90 ++++++++ client/src/context/AuthContext.tsx | 65 ++++++ client/src/main.tsx | 5 +- client/src/pages/HomePage.css | 41 +++- client/src/pages/HomePage.tsx | 225 ++++++++++++++------ db/migrate-auth.sql | 42 ++++ infra/docker/.env.prod.example | 5 + infra/docker/compose.truenas.yaml | 4 + package-lock.json | 118 ++++++++++ package.json | 3 + server/audit-log.js | 32 +++ server/index.js | 117 ++++++++-- server/middleware/auth.js | 27 +++ server/middleware/session.js | 38 ++++ server/migrate.js | 27 +++ server/routes/auth.js | 80 +++++++ 27 files changed, 1252 insertions(+), 112 deletions(-) create mode 100644 client/src/components/CuratorLoginModal.css create mode 100644 client/src/components/CuratorLoginModal.tsx create mode 100644 client/src/context/AuthContext.tsx create mode 100644 db/migrate-auth.sql create mode 100644 server/audit-log.js create mode 100644 server/middleware/auth.js create mode 100644 server/middleware/session.js create mode 100644 server/routes/auth.js diff --git a/.env.example b/.env.example index aa74e19..ed062ab 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,12 @@ TRUST_PROXY=true IMAGE_DIR=./data/images +# Curator auth (run npm run migrate after setting CURATOR_PASSWORD) +SESSION_SECRET=change-me-to-a-long-random-string +SESSION_COOKIE_SECURE=false +CURATOR_USERNAME=curator +CURATOR_PASSWORD= + # Production uses infra/docker/.env.prod → gallery_prod at gallery.mysuperlab.netcraze.pro:5173 # See Documentation/environments.md diff --git a/Documentation/API.md b/Documentation/API.md index 60a3542..994c9b8 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -26,6 +26,66 @@ curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds --- +## Authentication + +Anonymous visitors have implicit role **`user`** (browse only). **Curator** accounts unlock debug mode, the Checkup page, and all mutating audit routes. + +Sessions use an HTTP-only cookie (`gallery.sid`). The client sends `credentials: 'include'` on API requests. + +### `GET /api/auth/me` + +**Response (anonymous)** + +```json +{ "role": "user" } +``` + +**Response (curator session)** + +```json +{ "role": "curator", "username": "curator" } +``` + +### `POST /api/auth/login` + +**Body:** `{ "username": "curator", "password": "…" }` + +**Response:** `{ "role": "curator", "username": "curator" }` + +**Errors:** `401` invalid credentials, `400` missing fields. + +### `POST /api/auth/logout` + +Destroys the session cookie. + +**Response:** `{ "ok": true }` + +### Curator-only routes + +These return **`401`** with `{ "error": "Curator login required" }` without a valid curator session: + +| Route | Audit action (mutations only) | +|-------|-------------------------------| +| `GET /api/paintings/checkup` | — (read) | +| `GET /api/paintings/:id/debug-image-search` (+ `/more`) | — | +| `GET /api/artists/:id/debug-portrait-search` (+ `/more`) | — | +| `GET /api/debug/image-proxy` | — | +| `PATCH /api/paintings/:id/checkup-flags` | `painting.checkup_flags` | +| `PATCH /api/artists/:id/checkup-flags` | `artist.checkup_flags` | +| `POST /api/paintings/:id/fix-image` | `painting.fix_image` | +| `POST /api/paintings/:id/clear-image` | `painting.clear_image` | +| `POST /api/paintings/:id/upload-image` | `painting.upload_image` | +| `DELETE /api/paintings/:id` | `painting.delete` | +| `POST /api/artists/:id/fix-portrait` | `artist.fix_portrait` | +| `POST /api/artists/:id/clear-portrait` | `artist.clear_portrait` | +| `POST /api/artists/:id/upload-portrait` | `artist.upload_portrait` | + +**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static. + +Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_structure.md)). + +--- + ## `GET /api/bounds` Returns the overall timeline year range used to initialise the zoomable timeline. @@ -316,7 +376,9 @@ Both lists are grouped by art movement and exclude the current artist. Each arti ## `POST /api/artists/:id/preload-images` -Fast local scan: links paintings to files already on disk. Does **not** download from the internet (safe to call before opening the 3D gallery). +**Public** — no curator login required. + +Fast local scan: links paintings to files already on disk. Does **not** download from the internet. The 3D client calls this automatically when entering an **artist** hall. **Response** @@ -398,6 +460,8 @@ Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if ## Developer image audit +**Curator login required** for every route in this section. See [Authentication](#authentication) above. + Routes for the **Checkup** page and **Debug mode** on painting detail and artist bio. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id. ### `GET /api/paintings/checkup` @@ -590,10 +654,13 @@ Returns image bytes with appropriate `Content-Type`. ## Frontend helpers -The React client wraps these endpoints in `client/src/api/client.ts`: +The React client wraps these endpoints in `client/src/api/client.ts`. All requests send `credentials: 'include'` for session cookies. | Function | Maps to | |----------|---------| +| `getAuthMe()` | `GET /api/auth/me` | +| `loginCurator(user, pass)` | `POST /api/auth/login` | +| `logoutCurator()` | `POST /api/auth/logout` | | `api.getBounds()` | `GET /api/bounds` | | `api.getTimeline(start, end)` | `GET /api/timeline` | | `api.getArtists(...)` | `GET /api/artists` | diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 8d9a8e7..b27a13b 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -184,6 +184,51 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id **Canonical influence store.** Used by all API influence queries: painting detail (`influencedBy`, `influenced`), `has_influence_links`, and artist hall navigation (predecessors / successors). Legacy `painting_influences` rows are backfilled here on migration; new curated painting edges are written to both tables by `update-influences`. +### `users` + +Curator accounts (named logins). Anonymous site visitors do not have rows here. + +| Column | Type | Notes | +|--------|------|-------| +| `id` | SERIAL PK | | +| `username` | VARCHAR(64) UNIQUE | Login name | +| `password_hash` | VARCHAR(255) | bcrypt hash | +| `created_at` | TIMESTAMPTZ | | +| `last_login_at` | TIMESTAMPTZ | Updated on successful login | + +First curator is bootstrapped on `npm run migrate` when `users` is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in env. + +### `curator_audit_log` + +Append-only log of curator debug mutations (fix/clear/upload/delete, checkup flag changes). + +| Column | Type | Notes | +|--------|------|-------| +| `id` | BIGSERIAL PK | | +| `user_id` | FK → `users` | Who performed the action | +| `action` | VARCHAR(64) | e.g. `painting.fix_image`, `artist.upload_portrait` | +| `resource_type` | VARCHAR(32) | `painting` or `artist` | +| `resource_id` | INTEGER | Target row id | +| `details` | JSONB | Optional metadata (URL, mime type, flag values) | +| `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) | +| `created_at` | TIMESTAMPTZ | | + +**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`. + +Example query in pgAdmin: + +```sql +SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id, l.details +FROM curator_audit_log l +JOIN users u ON u.id = l.user_id +ORDER BY l.created_at DESC +LIMIT 50; +``` + +### `session` + +PostgreSQL session store for `express-session` (`connect-pg-simple`). Not application data. + ## Indexes - `artists(movement_id)`, `artists(century)` diff --git a/Documentation/FAC.md b/Documentation/FAC.md index 238002f..bef3593 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -78,11 +78,47 @@ copy .env.example .env # edit DB credentials, PUBLIC_URL npm install cd client; npm install; cd .. -npm run migrate # schema + incremental SQL +npm run migrate # schema + incremental SQL (+ auth tables, bootstrap curator) npm run setup # migrate + seed (fresh empty DB only) ``` -After clone with existing data/images, skip `setup` if DB already split — use post-seed steps below. +**Curator auth (after migrate):** set in `.env` before first `npm run migrate` if the DB has no curator yet: + +```env +SESSION_SECRET=your-long-random-secret +CURATOR_USERNAME=curator +CURATOR_PASSWORD=your-secure-password +``` + +Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup. Mutations are logged in `curator_audit_log` (view in pgAdmin). + +**Roles:** + +| Role | Access | +|------|--------| +| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios | +| Curator | Above + debug mode, Checkup, image fix/upload/delete APIs | + +**Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):** + +```sql +SELECT l.created_at, u.username, l.action, l.resource_type, l.resource_id +FROM curator_audit_log l +JOIN users u ON u.id = l.user_id +ORDER BY l.created_at DESC +LIMIT 30; +``` + +**Prod auth env** (TrueNAS app or `infra/docker/.env.prod`): + +```env +SESSION_SECRET=long-random-secret +SESSION_COOKIE_SECURE=true +CURATOR_USERNAME=curator +CURATOR_PASSWORD=your-secure-password +``` + +Run `npm run migrate` against prod DB after first deploy with auth vars set (creates tables + bootstrap curator if `users` is empty). --- @@ -107,6 +143,10 @@ After clone with existing data/images, skip `setup` if DB already split — use DB_NAME=gallery_dev PORT=3451 PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro +SESSION_SECRET=your-long-random-secret +SESSION_COOKIE_SECURE=false +CURATOR_USERNAME=curator +CURATOR_PASSWORD=your-secure-password ``` --- diff --git a/Documentation/basics.md b/Documentation/basics.md index 0ac417c..96e6957 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -300,7 +300,7 @@ Movement galleries do **not** use the predecessor/successor influence picker — ### Shared 3D behaviour -**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; call `POST /api/artists/:id/preload-images` before entering an **artist** hall to link disk files. Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. +**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. ## Painting detail view @@ -333,7 +333,9 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl ### Debug mode (developer) -When **Debug mode** is enabled from the home header, painting detail and artist biography show a bottom-left panel with image search preview and action buttons. See [Developer tools (image audit)](#developer-tools-image-audit). +**Curator login required.** Debug tools are hidden until you sign in from the home header (**Curator login**). After login, enable **Debug mode** from the same header area. + +When debug mode is on, painting detail and artist biography show a bottom-left panel with image search preview and action buttons. See [Developer tools (image audit)](#developer-tools-image-audit). Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens the **More** search-results modal automatically whenever you open a painting or artist bio while debug mode is on. @@ -347,18 +349,32 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens - **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button. - **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram. - **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit. -- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. +- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). +- **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again. - **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only. +## User roles and access + +| Role | Who | Can do | +|------|-----|--------| +| **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images | +| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, debug API mutations | + +Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session). + +Mutating debug actions (fix/clear/upload/delete, checkup flag changes) are appended to **`curator_audit_log`** with username, action, target id, optional JSON details, and client IP. Query in pgAdmin — see [DB_structure.md](DB_structure.md#curator_audit_log). + ## Developer tools (image audit) -Optional workflow for curating local image files — not part of the public visitor experience. +Curator-only workflow for reviewing and fixing local image files — not part of the public visitor experience. | Feature | Where | Purpose | |---------|--------|---------| -| **Debug mode** | Home header toggle (`client/src/utils/debugMode.ts`) | Persists in `localStorage`; enables debug panel on painting detail and artist bio | -| **Show more** | Home header checkbox (same util) | When debug mode is on, auto-opens the **More** modal on each painting / bio page load | -| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | +| **Curator login** | Home header (guests) | Username + password modal; unlocks debug tools | +| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio | +| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load | +| **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | +| **Logout** | Home header (curators) | Ends session; hides debug tools | | **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) | ### Debug panel (painting detail and artist bio) @@ -386,9 +402,11 @@ Influence side-panel thumbnails use **letterboxing** (`object-fit: contain`) so **Search visible** runs image search only for rows currently shown after text/filter — not automatically on page load. Fixing an image sets **Fixed** and **Reviewed**. -Run `npm run migrate:checkup-flags`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run start` (or `npm run dev:server`) so new routes (e.g. clear, upload, delete painting, portrait debug, annotations) are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode. +Run `npm run migrate:checkup-flags`, `npm run migrate:artist-checkup-flags`, and `npm run migrate:painting-annotations` once on existing databases. Load notes with `npm run update-painting-annotations` (add `--wikipedia` for overview lines from Wikipedia intro text). After server code changes, restart `npm run start` (or `npm run dev:server`) so new routes are registered. JSON body limit for uploads is **20 MB** (`express.json` in `server/index.js`); individual files are capped at **15 MB** after decode. -See [API.md](API.md#developer-image-audit) and [data-and-images.md](data-and-images.md#duplicate-paintings). +Server-side auth lives in `server/middleware/session.js`, `server/middleware/auth.js`, `server/routes/auth.js`, and `server/audit-log.js`. Client auth context: `client/src/context/AuthContext.tsx`. + +See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md#duplicate-paintings). ## Related docs diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index 141b88a..6e40cde 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -352,7 +352,7 @@ Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-dema ## Preload before 3D gallery -`POST /api/artists/:id/preload-images` runs **local-only** linking — no network. Call this when entering an **artist’s** 3D hall so textures use files already on disk. +`POST /api/artists/:id/preload-images` is a **public** route (no curator login). It runs **local-only** linking — no network. The React client calls it automatically when entering an **artist’s** 3D hall so textures use files already on disk. **Movement galleries** (`GET /api/movements/:id/gallery`) do not use preload — they load the full painting list from the API and resolve local paths the same way as artist halls. Works without files still show the canvas cover in the frame. @@ -496,4 +496,6 @@ The client passes `searchUrl`, `source`, and `thumbUrl` from search results to i Checkup **Search visible** queues search for filtered rows only (3 concurrent); it does not search the full catalog on load. +**Curator login required** for all debug/checkup UI and mutating API routes. Guests can browse and enter 3D halls normally; preload remains public. See [API.md — Authentication](API.md#authentication). + See [API.md](API.md#developer-image-audit) and [basics.md](basics.md#developer-tools-image-audit). diff --git a/Documentation/environments.md b/Documentation/environments.md index a7d768d..cfb1258 100644 --- a/Documentation/environments.md +++ b/Documentation/environments.md @@ -90,8 +90,14 @@ npm run db:split-databases DB_NAME=gallery_dev PORT=3451 PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro + SESSION_SECRET=your-long-random-secret + SESSION_COOKIE_SECURE=false + CURATOR_USERNAME=curator + CURATOR_PASSWORD=your-secure-password ``` + `npm run migrate` creates auth tables and bootstraps the first curator when `users` is empty. + 2. Run: ```powershell @@ -159,8 +165,10 @@ Use normal PowerShell with `-SkipHosts` if you already added `192.168.10.122 git 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** +4. Replace `REPLACE_WITH_LONG_RANDOM_SECRET` and `REPLACE_WITH_SECURE_PASSWORD` for `SESSION_SECRET` and `CURATOR_PASSWORD` +5. **Apps** → **Settings** → register Gitea registry (`gitea.mysuperlab.netcraze.pro`, token with `read:package`) +6. Deploy → wait for **gallery-web** to show **Running** +7. Run `npm run migrate` against `gallery_prod` if auth tables are not yet applied (or migrate from dev PC with prod env) ### Step H — Verify production @@ -171,7 +179,7 @@ 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. +Open **https://gallery.mysuperlab.netcraze.pro/** — timeline and sample painting images should load. Click an artist portrait or movement label to enter a 3D hall. **Curator login** (top-right) unlocks debug mode and Checkup. --- @@ -223,8 +231,12 @@ Expect **HTTP 200** (not 502). ```env PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro TRUST_PROXY=true +SESSION_SECRET=your-long-random-secret +SESSION_COOKIE_SECURE=false ``` +Prod (`infra/docker/.env.prod`): set `SESSION_COOKIE_SECURE=true` and the same `SESSION_SECRET` / `CURATOR_*` vars on the TrueNAS app environment. + Restart `npm run dev:web` after changing `PUBLIC_URL`. --- diff --git a/Documentation/setup.md b/Documentation/setup.md index c3c1752..9270d16 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -26,6 +26,12 @@ cp .env.example .env | `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`) | +| `SESSION_SECRET` | Random string for signed session cookies (required for curator login) | +| `SESSION_COOKIE_SECURE` | `false` for local HTTP dev; `true` in prod behind HTTPS | +| `CURATOR_USERNAME` | Bootstrap only — first curator account name (default `curator`) | +| `CURATOR_PASSWORD` | Bootstrap only — password for first curator when `users` table is empty | + +`.env` is git-ignored; never commit passwords. Optional script tuning: @@ -34,8 +40,6 @@ Optional script tuning: | `MIN_PAINTINGS` | `expand-catalog` | Minimum paintings per artist (default `6`) | | `FETCH_MAX_WAIT_SEC` | `fetch-images` | Max seconds per painting in batch runs (default `10`) | -`.env` is git-ignored; never commit passwords. - ## Install ```bash @@ -62,6 +66,14 @@ npm run seed # eras, movements, artists, flagship paintings (one per artis If migration fails with permission errors, grant schema rights to the app user first (see [DB_structure.md](DB_structure.md)). +### Curator accounts (auth migration) + +`npm run migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. + +After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in. + +See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit). + ### Recommended post-seed steps After a fresh seed, run these to match a fully populated local install: @@ -129,10 +141,11 @@ Production runs as **`gallery-web`** on TrueNAS at **https://gallery.mysuperlab. Quick deploy checklist: 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 +2. Dev `.env` → `DB_NAME=gallery_dev`, `PORT=3451`, `PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro`, plus auth vars (`SESSION_SECRET`, `CURATOR_*`) +3. `npm run migrate` on dev and prod DBs (includes auth tables + bootstrap curator) +4. `net use \\192.168.10.122\Gallery` → `npm run images:sync-to-prod` +5. `npm run docker:publish` → TrueNAS Custom App from `infra/docker/compose.truenas.yaml` (set auth env in compose) +6. Keenetic: both domains → `:5173`, protocol to device **`http`**, correct IP per environment ### Legacy deployment (optional) @@ -254,3 +267,8 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time | Movement gallery shows generic cream walls | Stale client build | `cd client && npm run build`; hard-refresh browser | | Windows overlap paintings in movement wing | Stale client | Rebuild client — windows are placed only on side walls in gaps between frames | | Influence thumbnails cropped on painting detail | Stale client build | `npm run build` — panels use `object-fit: contain` for full image | +| **Curator login** fails / always guest | Auth tables missing or wrong password | Set `SESSION_SECRET` + `CURATOR_PASSWORD` in `.env`, run `npm run migrate`, restart server | +| Debug / Checkup returns **401** | Not signed in as curator | **Curator login** (top-right); session cookie `gallery.sid` must be sent (`credentials: include`) | +| Debug works in UI but API rejects | Stale server without auth middleware | Restart `npm run dev:web` or `npm run dev:server` after pulling auth changes | +| **Empty screen** entering 3D hall (header missing) | Stale client before gallery-session fix | Hard-refresh; pull latest client — hall renders from `view` state, not only `gallerySession` | +| 3D hall black after returning from painting detail | WebGL context lost while hall was hidden | Hard-refresh; latest client remounts canvas when hall becomes active again | diff --git a/README.md b/README.md index c92fff4..3f5a24d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Art Gallery -Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. +Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, **curator-gated** debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**; painting detail also **Remove entry**), optional **Show more** auto-opens the search picker, Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies. Anonymous visitors browse freely; curators sign in via **Curator login** in the header. ## Documentation @@ -21,7 +21,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to- ## Setup -1. Copy `.env.example` to `.env` and set database credentials (`DB_NAME=gallery_dev` after [one-time split](Documentation/environments.md)). +1. Copy `.env.example` to `.env` and set database credentials (`DB_NAME=gallery_dev` after [one-time split](Documentation/environments.md)). Set `SESSION_SECRET`, `CURATOR_USERNAME`, and `CURATOR_PASSWORD` for curator login (see [setup.md](Documentation/setup.md)). 2. Install dependencies: ```bash diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 588461f..eb6a720 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -10,12 +10,47 @@ import type { const API = '/api'; -async function fetchJson(url: string): Promise { - const res = await fetch(url); +const fetchCredentials: RequestInit = { credentials: 'include' }; + +export type AuthRole = 'user' | 'curator'; + +export interface AuthState { + role: AuthRole; + username?: string; +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { ...fetchCredentials, ...init }); if (!res.ok) throw new Error(`API error: ${res.status}`); return res.json(); } +export async function getAuthMe(): Promise { + return fetchJson(`${API}/auth/me`); +} + +export async function loginCurator(username: string, password: string): Promise { + const res = await fetch(`${API}/auth/login`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Login failed: ${res.status}`); + } + return res.json(); +} + +export async function logoutCurator(): Promise { + const res = await fetch(`${API}/auth/logout`, { + ...fetchCredentials, + method: 'POST', + }); + if (!res.ok) throw new Error(`Logout failed: ${res.status}`); +} + export function imageUrl(path: string | null | undefined): string { if (!path) return '/placeholder-art.svg'; return `/images/${path}`; @@ -84,6 +119,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim async function postJsonImageAction(url: string, payload: { imageData: string; mimeType: string }): Promise { const res = await fetch(url, { + ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -96,7 +132,10 @@ async function postJsonImageAction(url: string, payload: { imageData: string; } export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> { - const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' }); + const res = await fetch(`${API}/artists/${artistId}/preload-images`, { + ...fetchCredentials, + method: 'POST', + }); if (!res.ok) throw new Error('Preload failed'); return res.json(); } @@ -198,6 +237,7 @@ export const api = { context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string } ) => fetch(`${API}/paintings/${id}/fix-image`, { + ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageUrl, ...context }), @@ -210,7 +250,7 @@ export const api = { }), clearPaintingImage: (id: number) => - fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => { + fetch(`${API}/paintings/${id}/clear-image`, { ...fetchCredentials, method: 'POST' }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Clear failed: ${res.status}`); @@ -219,7 +259,7 @@ export const api = { }), deletePainting: (id: number) => - fetch(`${API}/paintings/${id}`, { method: 'DELETE' }).then(async (res) => { + fetch(`${API}/paintings/${id}`, { ...fetchCredentials, method: 'DELETE' }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Remove failed: ${res.status}`); @@ -239,6 +279,7 @@ export const api = { flags: { checked?: boolean; fixed?: boolean } ) => fetch(`${API}/paintings/${id}/checkup-flags`, { + ...fetchCredentials, method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags), @@ -262,6 +303,7 @@ export const api = { context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string } ) => fetch(`${API}/artists/${id}/fix-portrait`, { + ...fetchCredentials, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageUrl, ...context }), @@ -274,7 +316,7 @@ export const api = { }), clearArtistPortrait: (id: number) => - fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => { + fetch(`${API}/artists/${id}/clear-portrait`, { ...fetchCredentials, method: 'POST' }).then(async (res) => { if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Clear failed: ${res.status}`); @@ -292,6 +334,7 @@ export const api = { flags: { checked?: boolean; fixed?: boolean } ) => fetch(`${API}/artists/${id}/checkup-flags`, { + ...fetchCredentials, method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags), diff --git a/client/src/components/CuratorLoginModal.css b/client/src/components/CuratorLoginModal.css new file mode 100644 index 0000000..7c33e06 --- /dev/null +++ b/client/src/components/CuratorLoginModal.css @@ -0,0 +1,132 @@ +.curator-login-backdrop { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.65); + padding: 16px; +} + +.curator-login-modal { + width: min(100%, 360px); + padding: 24px; + border-radius: 10px; + border: 1px solid rgba(201, 169, 110, 0.35); + background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); +} + +.curator-login-modal h2 { + margin: 0 0 8px; + font-family: 'Georgia', serif; + font-size: 20px; + color: #e8d5b5; +} + +.curator-login-hint { + margin: 0 0 20px; + font-size: 13px; + color: rgba(201, 169, 110, 0.7); + line-height: 1.45; +} + +.curator-login-field { + display: block; + margin-bottom: 14px; +} + +.curator-login-field span { + display: block; + margin-bottom: 6px; + font-size: 12px; + color: rgba(232, 213, 181, 0.85); +} + +.curator-login-field input { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid rgba(201, 169, 110, 0.35); + background: rgba(0, 0, 0, 0.35); + color: #e8d5b5; + font-size: 14px; +} + +.curator-login-field input:focus { + outline: 2px solid rgba(255, 220, 160, 0.45); + outline-offset: 1px; +} + +.curator-login-error { + margin: 0 0 12px; + font-size: 13px; + color: #f0a0a0; +} + +.curator-login-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.curator-login-cancel, +.curator-login-submit { + padding: 8px 14px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; +} + +.curator-login-cancel { + border: 1px solid rgba(201, 169, 110, 0.25); + background: transparent; + color: rgba(201, 169, 110, 0.8); +} + +.curator-login-submit { + border: 1px solid rgba(255, 220, 160, 0.45); + background: rgba(232, 160, 64, 0.2); + color: #e8d5b5; +} + +.curator-login-submit:disabled, +.curator-login-cancel:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.curator-login-gate { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px; + text-align: center; + background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%); + color: rgba(201, 169, 110, 0.85); +} + +.curator-login-gate h2 { + margin: 0; + font-family: 'Georgia', serif; + color: #e8d5b5; +} + +.curator-login-gate p { + margin: 0; + max-width: 420px; + line-height: 1.5; +} + +.curator-login-gate-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: center; +} diff --git a/client/src/components/CuratorLoginModal.tsx b/client/src/components/CuratorLoginModal.tsx new file mode 100644 index 0000000..7943941 --- /dev/null +++ b/client/src/components/CuratorLoginModal.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import './CuratorLoginModal.css'; + +interface Props { + open: boolean; + onClose: () => void; + onLogin: (username: string, password: string) => Promise; +} + +export default function CuratorLoginModal({ open, onClose, onLogin }: Props) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) { + setUsername(''); + setPassword(''); + setError(null); + setSubmitting(false); + } + }, [open]); + + if (!open) return null; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await onLogin(username.trim(), password); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +

Curator login

+

+ Debug tools and catalog edits require a curator account. +

+
+ + + {error &&

{error}

} +
+ + +
+
+
+
+ ); +} diff --git a/client/src/context/AuthContext.tsx b/client/src/context/AuthContext.tsx new file mode 100644 index 0000000..09a1ed5 --- /dev/null +++ b/client/src/context/AuthContext.tsx @@ -0,0 +1,65 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client'; + +interface AuthContextValue { + role: AuthRole; + username?: string; + isCurator: boolean; + loading: boolean; + login: (username: string, password: string) => Promise; + logout: () => Promise; + refresh: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [role, setRole] = useState('user'); + const [username, setUsername] = useState(); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + const me = await getAuthMe(); + setRole(me.role); + setUsername(me.username); + }, []); + + useEffect(() => { + refresh().finally(() => setLoading(false)); + }, [refresh]); + + const login = useCallback(async (user: string, password: string) => { + const me = await loginCurator(user, password); + setRole(me.role); + setUsername(me.username); + }, []); + + const logout = useCallback(async () => { + await logoutCurator(); + setRole('user'); + setUsername(undefined); + }, []); + + const value = useMemo( + () => ({ + role, + username, + isCurator: role === 'curator', + loading, + login, + logout, + refresh, + }), + [role, username, loading, login, logout, refresh] + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth must be used within AuthProvider'); + } + return ctx; +} diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..abde204 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { AuthProvider } from './context/AuthContext.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/client/src/pages/HomePage.css b/client/src/pages/HomePage.css index c67887c..0bbae3c 100644 --- a/client/src/pages/HomePage.css +++ b/client/src/pages/HomePage.css @@ -27,10 +27,20 @@ .gallery-session-suspended { position: fixed; inset: 0; - z-index: 0; + z-index: -1; visibility: hidden; pointer-events: none; - display: none; + opacity: 0; + overflow: hidden; +} + +.gallery-session-active { + position: fixed; + inset: 0; + z-index: 50; + visibility: visible; + pointer-events: auto; + opacity: 1; } .home-overlay { @@ -56,7 +66,9 @@ } .debug-mode-toggle, -.checkup-link-btn { +.checkup-link-btn, +.curator-login-btn, +.curator-logout-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(201, 169, 110, 0.35); @@ -69,7 +81,9 @@ } .debug-mode-toggle:hover, -.checkup-link-btn:hover { +.checkup-link-btn:hover, +.curator-login-btn:hover, +.curator-logout-btn:hover { border-color: #c9a96e; color: #e8d5b5; } @@ -116,6 +130,25 @@ opacity: 0.55; } +.curator-session-label { + font-size: 11px; + color: rgba(201, 169, 110, 0.65); + font-family: ui-monospace, 'Cascadia Code', monospace; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.curator-login-btn { + border-color: rgba(255, 220, 160, 0.35); +} + +.curator-logout-btn { + border-color: rgba(201, 169, 110, 0.25); + color: rgba(201, 169, 110, 0.6); +} + .checkup-link-btn { text-decoration: none; } diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 0266751..63c7b88 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -6,6 +6,9 @@ import VirtualGallery from '../components/VirtualGallery'; import PaintingDetailView from '../components/PaintingDetail'; import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; +import CuratorLoginModal from '../components/CuratorLoginModal'; +import '../components/CuratorLoginModal.css'; +import { useAuth } from '../context/AuthContext'; import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types'; import { createViewChangeScheduler } from '../utils/timelineView'; @@ -96,6 +99,7 @@ function catalogNavigateTarget( } export default function HomePage() { + const { isCurator, username, login, logout } = useAuth(); const [view, setView] = useState({ type: 'timeline' }); const [gallerySession, setGallerySession] = useState(null); const [bounds, setBounds] = useState({ min: -800, max: 2025 }); @@ -110,6 +114,9 @@ export default function HomePage() { const [portraitRevisions, setPortraitRevisions] = useState>({}); const [debugMode, setDebugMode] = useState(readDebugMode); const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore); + const [loginOpen, setLoginOpen] = useState(false); + const [loginRedirect, setLoginRedirect] = useState<'checkup' | null>(null); + const effectiveDebugMode = debugMode && isCurator; const [galleryRevision, setGalleryRevision] = useState(0); const viewRef = useRef(view); viewRef.current = view; @@ -193,6 +200,37 @@ export default function HomePage() { writeDebugShowMore(enabled); }; + const openCuratorLogin = (redirect: 'checkup' | null = null) => { + setLoginRedirect(redirect); + setLoginOpen(true); + }; + + const handleCuratorLogin = async (user: string, password: string) => { + await login(user, password); + setLoginOpen(false); + if (loginRedirect === 'checkup') { + setView({ type: 'checkup' }); + } + setLoginRedirect(null); + }; + + const handleCuratorLogout = async () => { + await logout(); + writeDebugMode(false); + setDebugMode(false); + if (view.type === 'checkup') { + setView({ type: 'timeline' }); + } + }; + + const openCheckup = () => { + if (!isCurator) { + openCuratorLogin('checkup'); + return; + } + setView({ type: 'checkup' }); + }; + const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => { const data = await api.getPainting(paintingId); const patch: Partial = { @@ -355,11 +393,22 @@ export default function HomePage() { [applyArtistPatch] ); + const openArtistGallery = useCallback((artistId: number, data: ArtistDetail) => { + const session: GallerySession = { kind: 'artist', artistId, data }; + setGallerySession(session); + setView({ type: 'gallery', artistId, data }); + }, []); + + const openMovementGallery = useCallback((movementId: number, data: MovementGalleryDetail) => { + const session: GallerySession = { kind: 'movement', movementId, data }; + setGallerySession(session); + setView({ type: 'movement-gallery', movementId, data }); + }, []); + const handleArtistClick = async (artistId: number) => { try { const data = await api.getArtist(artistId); - setGallerySession({ kind: 'artist', artistId, data }); - setView({ type: 'gallery', artistId, data }); + openArtistGallery(artistId, data); } catch { setError('Failed to load artist gallery.'); } @@ -368,8 +417,7 @@ export default function HomePage() { const handleMovementClick = async (movementId: number) => { try { const data = await api.getMovementGallery(movementId); - setGallerySession({ kind: 'movement', movementId, data }); - setView({ type: 'movement-gallery', movementId, data }); + openMovementGallery(movementId, data); } catch { setError('Failed to load movement gallery.'); } @@ -539,33 +587,46 @@ export default function HomePage() { const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery'; + const displayGallery = useMemo((): GallerySession | null => { + if (view.type === 'gallery') { + return { kind: 'artist', artistId: view.artistId, data: view.data }; + } + if (view.type === 'movement-gallery') { + return { kind: 'movement', movementId: view.movementId, data: view.data }; + } + return gallerySession; + }, [view, gallerySession]); + return ( <> - {gallerySession && ( -
- {gallerySession.kind === 'artist' ? ( + {displayGallery && ( +
+ {displayGallery.kind === 'artist' ? ( setView({ type: 'timeline' })} onBioClick={() => - handleBioClick(gallerySession.data, { + handleBioClick(displayGallery.data, { type: 'gallery', - artistId: gallerySession.artistId, - data: gallerySession.data, + artistId: displayGallery.artistId, + data: displayGallery.data, }) } /> ) : ( setView(view.returnTo)} onEnterGallery={() => - setView({ type: 'gallery', artistId: view.artistId, data: view.data }) + openArtistGallery(view.artistId, view.data) } onArtistPortraitFixed={handleArtistPortraitFixed} onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated} @@ -641,43 +698,91 @@ export default function HomePage() { )} {view.type === 'checkup' && ( - setView({ type: 'timeline' })} - onOpenPainting={handlePaintingClick} - /> + isCurator ? ( + setView({ type: 'timeline' })} + onOpenPainting={handlePaintingClick} + /> + ) : ( +
+

Curator access required

+

The painting checkup table is available to logged-in curators only.

+
+ + +
+
+ ) )} + { + setLoginOpen(false); + setLoginRedirect(null); + }} + onLogin={handleCuratorLogin} + /> + {view.type === 'timeline' && (
- - - + {isCurator ? ( + <> + + {username} + + + + + + + ) : ( + + )}

Virtual Art Gallery

Watch art movements branch forward through time — each flowing from what came before

diff --git a/db/migrate-auth.sql b/db/migrate-auth.sql new file mode 100644 index 0000000..b925967 --- /dev/null +++ b/db/migrate-auth.sql @@ -0,0 +1,42 @@ +-- Curator accounts and audit log (auth migration) + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS curator_audit_log ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + action VARCHAR(64) NOT NULL, + resource_type VARCHAR(32) NOT NULL, + resource_id INTEGER NOT NULL, + details JSONB, + ip_address VARCHAR(45), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_curator_audit_log_user_id ON curator_audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_curator_audit_log_created_at ON curator_audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_curator_audit_log_resource ON curator_audit_log(resource_type, resource_id); + +-- Session store for express-session (connect-pg-simple) +CREATE TABLE IF NOT EXISTS "session" ( + "sid" VARCHAR NOT NULL COLLATE "default", + "sess" JSON NOT NULL, + "expire" TIMESTAMP(6) NOT NULL +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'session_pkey' + ) THEN + ALTER TABLE "session" ADD CONSTRAINT session_pkey PRIMARY KEY ("sid"); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire"); diff --git a/infra/docker/.env.prod.example b/infra/docker/.env.prod.example index 6c70383..e386ad3 100644 --- a/infra/docker/.env.prod.example +++ b/infra/docker/.env.prod.example @@ -12,3 +12,8 @@ HOST=0.0.0.0 PUBLIC_URL=https://gallery.mysuperlab.netcraze.pro TRUST_PROXY=true IMAGE_DIR=/app/data/images + +SESSION_SECRET=change-me-to-a-long-random-string +SESSION_COOKIE_SECURE=true +CURATOR_USERNAME=curator +CURATOR_PASSWORD= diff --git a/infra/docker/compose.truenas.yaml b/infra/docker/compose.truenas.yaml index d8405fb..ebb2f95 100644 --- a/infra/docker/compose.truenas.yaml +++ b/infra/docker/compose.truenas.yaml @@ -28,6 +28,10 @@ services: PUBLIC_URL: https://gallery.mysuperlab.netcraze.pro TRUST_PROXY: "true" IMAGE_DIR: /app/data/images + SESSION_SECRET: "REPLACE_WITH_LONG_RANDOM_SECRET" + SESSION_COOKIE_SECURE: "true" + CURATOR_USERNAME: curator + CURATOR_PASSWORD: "REPLACE_WITH_SECURE_PASSWORD" volumes: - /mnt/BasePool/Applications/Gallery/data/images:/app/data/images extra_hosts: diff --git a/package-lock.json b/package-lock.json index 621bc48..e594746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^3.0.3", + "connect-pg-simple": "^10.0.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-session": "^1.19.0", "pg": "^8.21.0", "sharp": "^0.35.1" }, @@ -614,6 +617,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -753,6 +765,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "license": "MIT", + "dependencies": { + "pg": "^8.12.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -974,6 +998,50 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/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/express-session/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/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1414,6 +1482,15 @@ "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/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1618,6 +1695,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1671,6 +1757,26 @@ "node": ">= 18" } }, + "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", @@ -1970,6 +2076,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 9adfd4d..9af3d33 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,12 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "bcryptjs": "^3.0.3", + "connect-pg-simple": "^10.0.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-session": "^1.19.0", "pg": "^8.21.0", "sharp": "^0.35.1" }, diff --git a/server/audit-log.js b/server/audit-log.js new file mode 100644 index 0000000..9e855e5 --- /dev/null +++ b/server/audit-log.js @@ -0,0 +1,32 @@ +const pool = require('./db'); + +function clientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string' && forwarded.length > 0) { + return forwarded.split(',')[0].trim(); + } + return req.ip || null; +} + +async function logCuratorAction({ userId, action, resourceType, resourceId, details, req }) { + if (!userId) return; + + try { + await pool.query( + `INSERT INTO curator_audit_log (user_id, action, resource_type, resource_id, details, ip_address) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + userId, + action, + resourceType, + resourceId, + details ? JSON.stringify(details) : null, + req ? clientIp(req) : null, + ] + ); + } catch (err) { + console.error('Audit log error:', err.message); + } +} + +module.exports = { logCuratorAction }; diff --git a/server/index.js b/server/index.js index ec491c0..3d42e0c 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,10 @@ const fs = require('fs'); require('dotenv').config(); const pool = require('./db'); +const { createSessionMiddleware } = require('./middleware/session'); +const { requireCurator } = require('./middleware/auth'); +const { logCuratorAction } = require('./audit-log'); +const authRoutes = require('./routes/auth'); const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service'); const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); @@ -17,8 +21,10 @@ if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') { app.set('trust proxy', 1); } -app.use(cors()); +app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '20mb' })); +app.use(createSessionMiddleware()); +app.use('/api/auth', authRoutes); app.use('/images', express.static(IMAGE_DIR)); const INFLUENCE_LINKS_EXISTS = ` @@ -297,7 +303,7 @@ app.get('/api/artists/:id/navigation', async (req, res) => { }); // Update artist portrait checkup flags (checked / fixed) -app.patch('/api/artists/:id/checkup-flags', async (req, res) => { +app.patch('/api/artists/:id/checkup-flags', requireCurator, async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { checked, fixed } = req.body ?? {}; @@ -349,6 +355,15 @@ app.patch('/api/artists/:id/checkup-flags', async (req, res) => { checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed, }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'artist.checkup_flags', + resourceType: 'artist', + resourceId: artistId, + details: { checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed }, + req, + }); } catch (err) { console.error('Artist checkup flags error:', err.message); res.status(500).json({ error: 'Failed to update checkup flags' }); @@ -356,7 +371,7 @@ app.patch('/api/artists/:id/checkup-flags', async (req, res) => { }); // Developer debug: portrait image search for artist bio -app.get('/api/artists/:id/debug-portrait-search/more', async (req, res) => { +app.get('/api/artists/:id/debug-portrait-search/more', requireCurator, async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); @@ -374,7 +389,7 @@ app.get('/api/artists/:id/debug-portrait-search/more', async (req, res) => { } }); -app.get('/api/artists/:id/debug-portrait-search', async (req, res) => { +app.get('/api/artists/:id/debug-portrait-search', requireCurator, async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]); @@ -392,7 +407,7 @@ app.get('/api/artists/:id/debug-portrait-search', async (req, res) => { }); // Developer debug: replace artist portrait with a search result URL -app.post('/api/artists/:id/fix-portrait', async (req, res) => { +app.post('/api/artists/:id/fix-portrait', requireCurator, async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; @@ -411,13 +426,22 @@ app.post('/api/artists/:id/fix-portrait', async (req, res) => { [artistId] ); res.json({ ...updated, fixed: true, checked: true }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'artist.fix_portrait', + resourceType: 'artist', + resourceId: artistId, + details: { imageUrl, source: typeof source === 'string' ? source : undefined }, + req, + }); } catch (err) { console.error('Fix portrait error:', err.message); res.status(500).json({ error: friendlyImageFetchError(err) }); } }); -app.post('/api/artists/:id/clear-portrait', async (req, res) => { +app.post('/api/artists/:id/clear-portrait', requireCurator, async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const updated = await clearArtistPortrait(artistId); @@ -426,13 +450,21 @@ app.post('/api/artists/:id/clear-portrait', async (req, res) => { [artistId] ); res.json({ ...updated, fixed: true, checked: true }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'artist.clear_portrait', + resourceType: 'artist', + resourceId: artistId, + req, + }); } catch (err) { console.error('Clear portrait error:', err.message); res.status(500).json({ error: err.message || 'Could not clear portrait' }); } }); -app.post('/api/artists/:id/upload-portrait', async (req, res) => { +app.post('/api/artists/:id/upload-portrait', requireCurator, async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { imageData, mimeType } = req.body ?? {}; @@ -457,6 +489,15 @@ app.post('/api/artists/:id/upload-portrait', async (req, res) => { [artistId] ); res.json({ ...updated, fixed: true, checked: true }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'artist.upload_portrait', + resourceType: 'artist', + resourceId: artistId, + details: { mimeType: typeof mimeType === 'string' ? mimeType : 'image/jpeg', bytes: buffer.length }, + req, + }); } catch (err) { console.error('Upload portrait error:', err.message); res.status(500).json({ error: err.message || 'Could not upload portrait' }); @@ -507,7 +548,7 @@ app.get('/api/artists/:id', async (req, res) => { }); // Painting image checkup (developer audit table) — must be before /api/paintings/:id -app.get('/api/paintings/checkup', async (_req, res) => { +app.get('/api/paintings/checkup', requireCurator, async (_req, res) => { try { const { rows } = await pool.query( `SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path, @@ -555,7 +596,7 @@ app.get('/api/paintings/checkup', async (_req, res) => { }); // Update checkup workflow flags (checked / fixed) -app.patch('/api/paintings/:id/checkup-flags', async (req, res) => { +app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { checked, fixed } = req.body ?? {}; @@ -608,6 +649,15 @@ app.patch('/api/paintings/:id/checkup-flags', async (req, res) => { checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed, }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'painting.checkup_flags', + resourceType: 'painting', + resourceId: paintingId, + details: { checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed }, + req, + }); } catch (err) { console.error('Checkup flags error:', err.message); res.status(500).json({ error: 'Failed to update checkup flags' }); @@ -669,7 +719,7 @@ app.post('/api/artists/:id/preload-images', async (req, res) => { }); // Developer debug: Google Images first result for image audit -app.get('/api/paintings/:id/debug-image-search/more', async (req, res) => { +app.get('/api/paintings/:id/debug-image-search/more', requireCurator, async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); @@ -693,7 +743,7 @@ app.get('/api/paintings/:id/debug-image-search/more', async (req, res) => { } }); -app.get('/api/paintings/:id/debug-image-search', async (req, res) => { +app.get('/api/paintings/:id/debug-image-search', requireCurator, async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const result = await pool.query( @@ -717,7 +767,7 @@ app.get('/api/paintings/:id/debug-image-search', async (req, res) => { }); // Developer debug: replace painting image with a search result URL -app.post('/api/paintings/:id/fix-image', async (req, res) => { +app.post('/api/paintings/:id/fix-image', requireCurator, async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; @@ -736,13 +786,22 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => { [paintingId] ); res.json({ ...updated, fixed: true, checked: true }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'painting.fix_image', + resourceType: 'painting', + resourceId: paintingId, + details: { imageUrl, source: typeof source === 'string' ? source : undefined }, + req, + }); } catch (err) { console.error('Fix image error:', err.message); res.status(500).json({ error: friendlyImageFetchError(err) }); } }); -app.delete('/api/paintings/:id', async (req, res) => { +app.delete('/api/paintings/:id', requireCurator, async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); if (!Number.isFinite(paintingId)) { @@ -750,6 +809,15 @@ app.delete('/api/paintings/:id', async (req, res) => { } const removed = await deletePainting(paintingId); res.json(removed); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'painting.delete', + resourceType: 'painting', + resourceId: paintingId, + details: { title: removed.title, artistId: removed.artistId }, + req, + }); } catch (err) { console.error('Delete painting error:', err.message); const status = err.message === 'Painting not found' ? 404 : 500; @@ -757,7 +825,7 @@ app.delete('/api/paintings/:id', async (req, res) => { } }); -app.post('/api/paintings/:id/clear-image', async (req, res) => { +app.post('/api/paintings/:id/clear-image', requireCurator, async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const updated = await clearPaintingImage(paintingId); @@ -766,13 +834,21 @@ app.post('/api/paintings/:id/clear-image', async (req, res) => { [paintingId] ); res.json({ ...updated, fixed: true, checked: true }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'painting.clear_image', + resourceType: 'painting', + resourceId: paintingId, + req, + }); } catch (err) { console.error('Clear image error:', err.message); res.status(500).json({ error: err.message || 'Could not clear image' }); } }); -app.post('/api/paintings/:id/upload-image', async (req, res) => { +app.post('/api/paintings/:id/upload-image', requireCurator, async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { imageData, mimeType } = req.body ?? {}; @@ -797,6 +873,15 @@ app.post('/api/paintings/:id/upload-image', async (req, res) => { [paintingId] ); res.json({ ...updated, fixed: true, checked: true }); + + await logCuratorAction({ + userId: req.curatorUser.id, + action: 'painting.upload_image', + resourceType: 'painting', + resourceId: paintingId, + details: { mimeType: typeof mimeType === 'string' ? mimeType : 'image/jpeg', bytes: buffer.length }, + req, + }); } catch (err) { console.error('Upload image error:', err.message); res.status(500).json({ error: err.message || 'Could not upload image' }); @@ -804,7 +889,7 @@ app.post('/api/paintings/:id/upload-image', async (req, res) => { }); // Proxy remote image for debug preview (avoids hotlink / CORS blocks) -app.get('/api/debug/image-proxy', async (req, res) => { +app.get('/api/debug/image-proxy', requireCurator, async (req, res) => { try { const imageUrl = req.query.url; const searchUrl = req.query.searchUrl; diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..7253387 --- /dev/null +++ b/server/middleware/auth.js @@ -0,0 +1,27 @@ +const pool = require('../db'); + +async function requireCurator(req, res, next) { + const userId = req.session?.userId; + if (!userId) { + return res.status(401).json({ error: 'Curator login required' }); + } + + try { + const { rows } = await pool.query( + `SELECT id, username FROM users WHERE id = $1`, + [userId] + ); + if (rows.length === 0) { + req.session.destroy(() => {}); + return res.status(401).json({ error: 'Curator login required' }); + } + + req.curatorUser = rows[0]; + next(); + } catch (err) { + console.error('Auth middleware error:', err.message); + res.status(500).json({ error: 'Authentication failed' }); + } +} + +module.exports = { requireCurator }; diff --git a/server/middleware/session.js b/server/middleware/session.js new file mode 100644 index 0000000..7df2b61 --- /dev/null +++ b/server/middleware/session.js @@ -0,0 +1,38 @@ +const session = require('express-session'); +const pgSession = require('connect-pg-simple')(session); +const pool = require('../db'); + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +function createSessionMiddleware() { + const secret = process.env.SESSION_SECRET; + if (!secret) { + console.warn( + 'SESSION_SECRET is not set — using insecure default (set SESSION_SECRET in production)' + ); + } + + const secureCookie = + process.env.SESSION_COOKIE_SECURE === '1' || + process.env.SESSION_COOKIE_SECURE === 'true'; + + return session({ + store: new pgSession({ + pool, + tableName: 'session', + createTableIfMissing: false, + }), + name: 'gallery.sid', + secret: secret || 'gallery-dev-insecure-session-secret', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: secureCookie, + sameSite: 'lax', + maxAge: SEVEN_DAYS_MS, + }, + }); +} + +module.exports = { createSessionMiddleware }; diff --git a/server/migrate.js b/server/migrate.js index 5abf53f..c1a8896 100644 --- a/server/migrate.js +++ b/server/migrate.js @@ -9,8 +9,32 @@ const INCREMENTAL_MIGRATIONS = [ 'migrate-influence-sources.sql', 'migrate-painting-annotations.sql', 'migrate-artist-palette.sql', + 'migrate-auth.sql', ]; +async function bootstrapCurator() { + const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM users'); + if (rows[0].n > 0) { + console.log(' curator bootstrap: users table already populated'); + return; + } + + const username = (process.env.CURATOR_USERNAME || 'curator').trim(); + const password = process.env.CURATOR_PASSWORD; + if (!password) { + console.warn(' curator bootstrap skipped: set CURATOR_PASSWORD to create the first curator account'); + return; + } + + const bcrypt = require('bcryptjs'); + const passwordHash = await bcrypt.hash(password, 10); + await pool.query( + `INSERT INTO users (username, password_hash) VALUES ($1, $2)`, + [username, passwordHash] + ); + console.log(` bootstrap curator account: ${username}`); +} + async function applySqlFile(label, filePath) { const sql = fs.readFileSync(filePath, 'utf8'); await pool.query(sql); @@ -34,6 +58,9 @@ async function migrate() { await applySqlFile(file, filePath); } + console.log('Bootstrapping curator account (if needed) …'); + await bootstrapCurator(); + console.log('Database migration complete.'); } diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..0f5c865 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,80 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const pool = require('../db'); + +const router = express.Router(); + +router.get('/me', async (req, res) => { + const userId = req.session?.userId; + if (!userId) { + return res.json({ role: 'user' }); + } + + try { + const { rows } = await pool.query( + `SELECT id, username FROM users WHERE id = $1`, + [userId] + ); + if (rows.length === 0) { + req.session.destroy(() => {}); + return res.json({ role: 'user' }); + } + + res.json({ + role: 'curator', + username: rows[0].username, + }); + } catch (err) { + console.error('Auth me error:', err.message); + res.status(500).json({ error: 'Failed to read session' }); + } +}); + +router.post('/login', async (req, res) => { + const { username, password } = req.body ?? {}; + if (!username || typeof username !== 'string' || !password || typeof password !== 'string') { + return res.status(400).json({ error: 'Username and password required' }); + } + + try { + const { rows } = await pool.query( + `SELECT id, username, password_hash FROM users WHERE username = $1`, + [username.trim()] + ); + if (rows.length === 0) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + const user = rows[0]; + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]); + + req.session.userId = user.id; + req.session.username = user.username; + + res.json({ + role: 'curator', + username: user.username, + }); + } catch (err) { + console.error('Auth login error:', err.message); + res.status(500).json({ error: 'Login failed' }); + } +}); + +router.post('/logout', (req, res) => { + req.session.destroy((err) => { + if (err) { + console.error('Auth logout error:', err.message); + return res.status(500).json({ error: 'Logout failed' }); + } + res.clearCookie('gallery.sid'); + res.json({ ok: true }); + }); +}); + +module.exports = router;