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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+69
-2
@@ -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` |
|
||||
|
||||
@@ -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)`
|
||||
|
||||
+42
-2
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+27
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
+24
-6
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,12 +10,47 @@ import type {
|
||||
|
||||
const API = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<AuthState> {
|
||||
return fetchJson<AuthState>(`${API}/auth/me`);
|
||||
}
|
||||
|
||||
export async function loginCurator(username: string, password: string): Promise<AuthState> {
|
||||
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<void> {
|
||||
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<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -96,7 +132,10 @@ async function postJsonImageAction<T>(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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="curator-login-backdrop" onMouseDown={onClose}>
|
||||
<div
|
||||
className="curator-login-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="curator-login-title"
|
||||
aria-modal="true"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="curator-login-title">Curator login</h2>
|
||||
<p className="curator-login-hint">
|
||||
Debug tools and catalog edits require a curator account.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="curator-login-field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="curator-login-field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="curator-login-error">{error}</p>}
|
||||
<div className="curator-login-actions">
|
||||
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="curator-login-submit" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRole] = useState<AuthRole>('user');
|
||||
const [username, setUsername] = useState<string | undefined>();
|
||||
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
+4
-1
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+165
-60
@@ -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<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
@@ -110,6 +114,9 @@ export default function HomePage() {
|
||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||
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<Painting> = {
|
||||
@@ -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 && (
|
||||
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
||||
{gallerySession.kind === 'artist' ? (
|
||||
{displayGallery && (
|
||||
<div
|
||||
className={galleryActive ? 'gallery-session-active' : 'gallery-session-suspended'}
|
||||
aria-hidden={!galleryActive}
|
||||
>
|
||||
{displayGallery.kind === 'artist' ? (
|
||||
<VirtualGallery
|
||||
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
|
||||
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||
mode="artist"
|
||||
data={gallerySession.data}
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onNavigateArtist={handleArtistClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBioClick={() =>
|
||||
handleBioClick(gallerySession.data, {
|
||||
handleBioClick(displayGallery.data, {
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
artistId: displayGallery.artistId,
|
||||
data: displayGallery.data,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<VirtualGallery
|
||||
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
|
||||
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||
mode="movement"
|
||||
data={gallerySession.data}
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
@@ -588,21 +649,17 @@ export default function HomePage() {
|
||||
gallerySession?.kind === 'artist' &&
|
||||
gallerySession.artistId === returnTo.artistId
|
||||
) {
|
||||
setView({
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
openArtistGallery(gallerySession.artistId, gallerySession.data);
|
||||
} else if (
|
||||
returnTo.type === 'movement-gallery' &&
|
||||
gallerySession?.kind === 'movement' &&
|
||||
gallerySession.movementId === returnTo.movementId
|
||||
) {
|
||||
setView({
|
||||
type: 'movement-gallery',
|
||||
movementId: gallerySession.movementId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
openMovementGallery(gallerySession.movementId, gallerySession.data);
|
||||
} else if (returnTo.type === 'gallery') {
|
||||
openArtistGallery(returnTo.artistId, returnTo.data);
|
||||
} else if (returnTo.type === 'movement-gallery') {
|
||||
openMovementGallery(returnTo.movementId, returnTo.data);
|
||||
} else {
|
||||
setView(returnTo);
|
||||
}
|
||||
@@ -614,8 +671,8 @@ export default function HomePage() {
|
||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||
}}
|
||||
onInfluenceArtistClick={handleArtistClick}
|
||||
debugMode={debugMode}
|
||||
debugShowMore={debugShowMore}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
onPaintingImageFixed={handlePaintingImageFixed}
|
||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||
onPaintingRemoved={handlePaintingRemoved}
|
||||
@@ -627,12 +684,12 @@ export default function HomePage() {
|
||||
<div className="home-overlay">
|
||||
<ArtistBio
|
||||
artist={view.data.artist}
|
||||
debugMode={debugMode}
|
||||
debugShowMore={debugShowMore}
|
||||
debugMode={effectiveDebugMode}
|
||||
debugShowMore={debugShowMore && isCurator}
|
||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||
onBack={() => 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' && (
|
||||
<CheckupPage
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
isCurator ? (
|
||||
<CheckupPage
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>Curator access required</h2>
|
||||
<p>The painting checkup table is available to logged-in curators only.</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
|
||||
Curator login
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={() => setView({ type: 'timeline' })}>
|
||||
Back to gallery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<CuratorLoginModal
|
||||
open={loginOpen}
|
||||
onClose={() => {
|
||||
setLoginOpen(false);
|
||||
setLoginRedirect(null);
|
||||
}}
|
||||
onLogin={handleCuratorLogin}
|
||||
/>
|
||||
|
||||
{view.type === 'timeline' && (
|
||||
<div className="home-page">
|
||||
<header className="site-header">
|
||||
<div className="site-dev-tools">
|
||||
<button
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
onClick={toggleDebugMode}
|
||||
title="Toggle developer image audit mode on painting details and artist bios"
|
||||
>
|
||||
Debug mode{debugMode ? ': ON' : ''}
|
||||
</button>
|
||||
<label
|
||||
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugShowMore}
|
||||
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={() => setView({ type: 'checkup' })}
|
||||
title="Open painting image checkup table"
|
||||
>
|
||||
Checkup
|
||||
</button>
|
||||
{isCurator ? (
|
||||
<>
|
||||
<span className="curator-session-label" title={`Signed in as ${username}`}>
|
||||
{username}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||
onClick={toggleDebugMode}
|
||||
title="Toggle developer image audit mode on painting details and artist bios"
|
||||
>
|
||||
Debug mode{debugMode ? ': ON' : ''}
|
||||
</button>
|
||||
<label
|
||||
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
|
||||
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugShowMore}
|
||||
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openCheckup}
|
||||
title="Open painting image checkup table"
|
||||
>
|
||||
Checkup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="curator-logout-btn"
|
||||
onClick={handleCuratorLogout}
|
||||
title="Sign out curator session"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="curator-login-btn"
|
||||
onClick={() => openCuratorLogin()}
|
||||
title="Sign in as curator to use debug tools"
|
||||
>
|
||||
Curator login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1>Virtual Art Gallery</h1>
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
|
||||
@@ -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");
|
||||
@@ -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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+118
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
+101
-16
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user