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
|
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
|
# Production uses infra/docker/.env.prod → gallery_prod at gallery.mysuperlab.netcraze.pro:5173
|
||||||
# See Documentation/environments.md
|
# 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`
|
## `GET /api/bounds`
|
||||||
|
|
||||||
Returns the overall timeline year range used to initialise the zoomable timeline.
|
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`
|
## `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**
|
**Response**
|
||||||
|
|
||||||
@@ -398,6 +460,8 @@ Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if
|
|||||||
|
|
||||||
## Developer image audit
|
## 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.
|
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`
|
### `GET /api/paintings/checkup`
|
||||||
@@ -590,10 +654,13 @@ Returns image bytes with appropriate `Content-Type`.
|
|||||||
|
|
||||||
## Frontend helpers
|
## 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 |
|
| 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.getBounds()` | `GET /api/bounds` |
|
||||||
| `api.getTimeline(start, end)` | `GET /api/timeline` |
|
| `api.getTimeline(start, end)` | `GET /api/timeline` |
|
||||||
| `api.getArtists(...)` | `GET /api/artists` |
|
| `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`.
|
**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
|
## Indexes
|
||||||
|
|
||||||
- `artists(movement_id)`, `artists(century)`
|
- `artists(movement_id)`, `artists(century)`
|
||||||
|
|||||||
+42
-2
@@ -78,11 +78,47 @@ copy .env.example .env # edit DB credentials, PUBLIC_URL
|
|||||||
npm install
|
npm install
|
||||||
cd client; npm install; cd ..
|
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)
|
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
|
DB_NAME=gallery_dev
|
||||||
PORT=3451
|
PORT=3451
|
||||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
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
|
### 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
|
## 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)
|
### 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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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)
|
## 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 |
|
| 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 |
|
| **Curator login** | Home header (guests) | Username + password modal; unlocks debug tools |
|
||||||
| **Show more** | Home header checkbox (same util) | When debug mode is on, auto-opens the **More** modal on each painting / bio page load |
|
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
|
||||||
| **Checkup page** | Home header → **Checkup** (`CheckupPage.tsx`) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags |
|
| **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 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)
|
### 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**.
|
**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
|
## Related docs
|
||||||
|
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ Requests are deduplicated (`inflight` map) and timeout after 15 seconds. On-dema
|
|||||||
|
|
||||||
## Preload before 3D gallery
|
## 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.
|
**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.
|
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).
|
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
|
DB_NAME=gallery_dev
|
||||||
PORT=3451
|
PORT=3451
|
||||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
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:
|
2. Run:
|
||||||
|
|
||||||
```powershell
|
```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**
|
1. **Apps** → **Discover Apps** → **Custom App** → **Install via Docker Compose**
|
||||||
2. Paste contents of `infra/docker/compose.truenas.yaml` from the repo
|
2. Paste contents of `infra/docker/compose.truenas.yaml` from the repo
|
||||||
3. Replace `YOUR_POSTGRES_PASSWORD` with the `gallery` user password
|
3. Replace `YOUR_POSTGRES_PASSWORD` with the `gallery` user password
|
||||||
4. **Apps** → **Settings** → register Gitea registry (`gitea.mysuperlab.netcraze.pro`, token with `read:package`)
|
4. Replace `REPLACE_WITH_LONG_RANDOM_SECRET` and `REPLACE_WITH_SECURE_PASSWORD` for `SESSION_SECRET` and `CURATOR_PASSWORD`
|
||||||
5. Deploy → wait for **gallery-web** to show **Running**
|
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
|
### 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
|
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
|
```env
|
||||||
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
|
||||||
TRUST_PROXY=true
|
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`.
|
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`) |
|
| `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-*`) |
|
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
|
||||||
| `IMAGE_DIR` | Root for cached images (default `./data/images`) |
|
| `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:
|
Optional script tuning:
|
||||||
|
|
||||||
@@ -34,8 +40,6 @@ Optional script tuning:
|
|||||||
| `MIN_PAINTINGS` | `expand-catalog` | Minimum paintings per artist (default `6`) |
|
| `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`) |
|
| `FETCH_MAX_WAIT_SEC` | `fetch-images` | Max seconds per painting in batch runs (default `10`) |
|
||||||
|
|
||||||
`.env` is git-ignored; never commit passwords.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```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)).
|
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
|
### Recommended post-seed steps
|
||||||
|
|
||||||
After a fresh seed, run these to match a fully populated local install:
|
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:
|
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)
|
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`
|
2. Dev `.env` → `DB_NAME=gallery_dev`, `PORT=3451`, `PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro`, plus auth vars (`SESSION_SECRET`, `CURATOR_*`)
|
||||||
3. `net use \\192.168.10.122\Gallery` → `npm run images:sync-to-prod`
|
3. `npm run migrate` on dev and prod DBs (includes auth tables + bootstrap curator)
|
||||||
4. `npm run docker:publish` → TrueNAS Custom App from `infra/docker/compose.truenas.yaml`
|
4. `net use \\192.168.10.122\Gallery` → `npm run images:sync-to-prod`
|
||||||
5. Keenetic: both domains → `:5173`, protocol to device **`http`**, correct IP per environment
|
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)
|
### 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 |
|
| 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 |
|
| 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 |
|
| 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
|
# 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
|
## Documentation
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
|
|||||||
|
|
||||||
## Setup
|
## 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:
|
2. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -10,12 +10,47 @@ import type {
|
|||||||
|
|
||||||
const API = '/api';
|
const API = '/api';
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
const fetchCredentials: RequestInit = { credentials: 'include' };
|
||||||
const res = await fetch(url);
|
|
||||||
|
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}`);
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
return res.json();
|
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 {
|
export function imageUrl(path: string | null | undefined): string {
|
||||||
if (!path) return '/placeholder-art.svg';
|
if (!path) return '/placeholder-art.svg';
|
||||||
return `/images/${path}`;
|
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> {
|
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
...fetchCredentials,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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 }> {
|
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');
|
if (!res.ok) throw new Error('Preload failed');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -198,6 +237,7 @@ export const api = {
|
|||||||
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
||||||
) =>
|
) =>
|
||||||
fetch(`${API}/paintings/${id}/fix-image`, {
|
fetch(`${API}/paintings/${id}/fix-image`, {
|
||||||
|
...fetchCredentials,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ imageUrl, ...context }),
|
body: JSON.stringify({ imageUrl, ...context }),
|
||||||
@@ -210,7 +250,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
clearPaintingImage: (id: number) =>
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error || `Clear failed: ${res.status}`);
|
throw new Error(body.error || `Clear failed: ${res.status}`);
|
||||||
@@ -219,7 +259,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
deletePainting: (id: number) =>
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error || `Remove failed: ${res.status}`);
|
throw new Error(body.error || `Remove failed: ${res.status}`);
|
||||||
@@ -239,6 +279,7 @@ export const api = {
|
|||||||
flags: { checked?: boolean; fixed?: boolean }
|
flags: { checked?: boolean; fixed?: boolean }
|
||||||
) =>
|
) =>
|
||||||
fetch(`${API}/paintings/${id}/checkup-flags`, {
|
fetch(`${API}/paintings/${id}/checkup-flags`, {
|
||||||
|
...fetchCredentials,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(flags),
|
body: JSON.stringify(flags),
|
||||||
@@ -262,6 +303,7 @@ export const api = {
|
|||||||
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
|
||||||
) =>
|
) =>
|
||||||
fetch(`${API}/artists/${id}/fix-portrait`, {
|
fetch(`${API}/artists/${id}/fix-portrait`, {
|
||||||
|
...fetchCredentials,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ imageUrl, ...context }),
|
body: JSON.stringify({ imageUrl, ...context }),
|
||||||
@@ -274,7 +316,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
clearArtistPortrait: (id: number) =>
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error || `Clear failed: ${res.status}`);
|
throw new Error(body.error || `Clear failed: ${res.status}`);
|
||||||
@@ -292,6 +334,7 @@ export const api = {
|
|||||||
flags: { checked?: boolean; fixed?: boolean }
|
flags: { checked?: boolean; fixed?: boolean }
|
||||||
) =>
|
) =>
|
||||||
fetch(`${API}/artists/${id}/checkup-flags`, {
|
fetch(`${API}/artists/${id}/checkup-flags`, {
|
||||||
|
...fetchCredentials,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(flags),
|
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;
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './context/AuthContext.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,10 +27,20 @@
|
|||||||
.gallery-session-suspended {
|
.gallery-session-suspended {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: -1;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
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 {
|
.home-overlay {
|
||||||
@@ -56,7 +66,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.debug-mode-toggle,
|
.debug-mode-toggle,
|
||||||
.checkup-link-btn {
|
.checkup-link-btn,
|
||||||
|
.curator-login-btn,
|
||||||
|
.curator-logout-btn {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||||
@@ -69,7 +81,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.debug-mode-toggle:hover,
|
.debug-mode-toggle:hover,
|
||||||
.checkup-link-btn:hover {
|
.checkup-link-btn:hover,
|
||||||
|
.curator-login-btn:hover,
|
||||||
|
.curator-logout-btn:hover {
|
||||||
border-color: #c9a96e;
|
border-color: #c9a96e;
|
||||||
color: #e8d5b5;
|
color: #e8d5b5;
|
||||||
}
|
}
|
||||||
@@ -116,6 +130,25 @@
|
|||||||
opacity: 0.55;
|
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 {
|
.checkup-link-btn {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-30
@@ -6,6 +6,9 @@ import VirtualGallery from '../components/VirtualGallery';
|
|||||||
import PaintingDetailView from '../components/PaintingDetail';
|
import PaintingDetailView from '../components/PaintingDetail';
|
||||||
import ArtistBio from '../components/ArtistBio';
|
import ArtistBio from '../components/ArtistBio';
|
||||||
import CheckupPage from '../pages/CheckupPage';
|
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 { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||||
@@ -96,6 +99,7 @@ function catalogNavigateTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const { isCurator, username, login, logout } = useAuth();
|
||||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||||
@@ -110,6 +114,9 @@ export default function HomePage() {
|
|||||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||||
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
|
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 [galleryRevision, setGalleryRevision] = useState(0);
|
||||||
const viewRef = useRef(view);
|
const viewRef = useRef(view);
|
||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
@@ -193,6 +200,37 @@ export default function HomePage() {
|
|||||||
writeDebugShowMore(enabled);
|
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 handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||||
const data = await api.getPainting(paintingId);
|
const data = await api.getPainting(paintingId);
|
||||||
const patch: Partial<Painting> = {
|
const patch: Partial<Painting> = {
|
||||||
@@ -355,11 +393,22 @@ export default function HomePage() {
|
|||||||
[applyArtistPatch]
|
[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) => {
|
const handleArtistClick = async (artistId: number) => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getArtist(artistId);
|
const data = await api.getArtist(artistId);
|
||||||
setGallerySession({ kind: 'artist', artistId, data });
|
openArtistGallery(artistId, data);
|
||||||
setView({ type: 'gallery', artistId, data });
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load artist gallery.');
|
setError('Failed to load artist gallery.');
|
||||||
}
|
}
|
||||||
@@ -368,8 +417,7 @@ export default function HomePage() {
|
|||||||
const handleMovementClick = async (movementId: number) => {
|
const handleMovementClick = async (movementId: number) => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getMovementGallery(movementId);
|
const data = await api.getMovementGallery(movementId);
|
||||||
setGallerySession({ kind: 'movement', movementId, data });
|
openMovementGallery(movementId, data);
|
||||||
setView({ type: 'movement-gallery', movementId, data });
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load movement gallery.');
|
setError('Failed to load movement gallery.');
|
||||||
}
|
}
|
||||||
@@ -539,33 +587,46 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{gallerySession && (
|
{displayGallery && (
|
||||||
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
|
<div
|
||||||
{gallerySession.kind === 'artist' ? (
|
className={galleryActive ? 'gallery-session-active' : 'gallery-session-suspended'}
|
||||||
|
aria-hidden={!galleryActive}
|
||||||
|
>
|
||||||
|
{displayGallery.kind === 'artist' ? (
|
||||||
<VirtualGallery
|
<VirtualGallery
|
||||||
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
|
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||||
mode="artist"
|
mode="artist"
|
||||||
data={gallerySession.data}
|
data={displayGallery.data}
|
||||||
imageRevisions={imageRevisions}
|
imageRevisions={imageRevisions}
|
||||||
active={galleryActive}
|
active={galleryActive}
|
||||||
onPaintingClick={handlePaintingClick}
|
onPaintingClick={handlePaintingClick}
|
||||||
onNavigateArtist={handleArtistClick}
|
onNavigateArtist={handleArtistClick}
|
||||||
onBack={() => setView({ type: 'timeline' })}
|
onBack={() => setView({ type: 'timeline' })}
|
||||||
onBioClick={() =>
|
onBioClick={() =>
|
||||||
handleBioClick(gallerySession.data, {
|
handleBioClick(displayGallery.data, {
|
||||||
type: 'gallery',
|
type: 'gallery',
|
||||||
artistId: gallerySession.artistId,
|
artistId: displayGallery.artistId,
|
||||||
data: gallerySession.data,
|
data: displayGallery.data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<VirtualGallery
|
<VirtualGallery
|
||||||
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
|
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||||
mode="movement"
|
mode="movement"
|
||||||
data={gallerySession.data}
|
data={displayGallery.data}
|
||||||
imageRevisions={imageRevisions}
|
imageRevisions={imageRevisions}
|
||||||
active={galleryActive}
|
active={galleryActive}
|
||||||
onPaintingClick={handlePaintingClick}
|
onPaintingClick={handlePaintingClick}
|
||||||
@@ -588,21 +649,17 @@ export default function HomePage() {
|
|||||||
gallerySession?.kind === 'artist' &&
|
gallerySession?.kind === 'artist' &&
|
||||||
gallerySession.artistId === returnTo.artistId
|
gallerySession.artistId === returnTo.artistId
|
||||||
) {
|
) {
|
||||||
setView({
|
openArtistGallery(gallerySession.artistId, gallerySession.data);
|
||||||
type: 'gallery',
|
|
||||||
artistId: gallerySession.artistId,
|
|
||||||
data: gallerySession.data,
|
|
||||||
});
|
|
||||||
} else if (
|
} else if (
|
||||||
returnTo.type === 'movement-gallery' &&
|
returnTo.type === 'movement-gallery' &&
|
||||||
gallerySession?.kind === 'movement' &&
|
gallerySession?.kind === 'movement' &&
|
||||||
gallerySession.movementId === returnTo.movementId
|
gallerySession.movementId === returnTo.movementId
|
||||||
) {
|
) {
|
||||||
setView({
|
openMovementGallery(gallerySession.movementId, gallerySession.data);
|
||||||
type: 'movement-gallery',
|
} else if (returnTo.type === 'gallery') {
|
||||||
movementId: gallerySession.movementId,
|
openArtistGallery(returnTo.artistId, returnTo.data);
|
||||||
data: gallerySession.data,
|
} else if (returnTo.type === 'movement-gallery') {
|
||||||
});
|
openMovementGallery(returnTo.movementId, returnTo.data);
|
||||||
} else {
|
} else {
|
||||||
setView(returnTo);
|
setView(returnTo);
|
||||||
}
|
}
|
||||||
@@ -614,8 +671,8 @@ export default function HomePage() {
|
|||||||
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
|
||||||
}}
|
}}
|
||||||
onInfluenceArtistClick={handleArtistClick}
|
onInfluenceArtistClick={handleArtistClick}
|
||||||
debugMode={debugMode}
|
debugMode={effectiveDebugMode}
|
||||||
debugShowMore={debugShowMore}
|
debugShowMore={debugShowMore && isCurator}
|
||||||
onPaintingImageFixed={handlePaintingImageFixed}
|
onPaintingImageFixed={handlePaintingImageFixed}
|
||||||
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
|
||||||
onPaintingRemoved={handlePaintingRemoved}
|
onPaintingRemoved={handlePaintingRemoved}
|
||||||
@@ -627,12 +684,12 @@ export default function HomePage() {
|
|||||||
<div className="home-overlay">
|
<div className="home-overlay">
|
||||||
<ArtistBio
|
<ArtistBio
|
||||||
artist={view.data.artist}
|
artist={view.data.artist}
|
||||||
debugMode={debugMode}
|
debugMode={effectiveDebugMode}
|
||||||
debugShowMore={debugShowMore}
|
debugShowMore={debugShowMore && isCurator}
|
||||||
portraitRevision={portraitRevisions[view.data.artist.id]}
|
portraitRevision={portraitRevisions[view.data.artist.id]}
|
||||||
onBack={() => setView(view.returnTo)}
|
onBack={() => setView(view.returnTo)}
|
||||||
onEnterGallery={() =>
|
onEnterGallery={() =>
|
||||||
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
|
openArtistGallery(view.artistId, view.data)
|
||||||
}
|
}
|
||||||
onArtistPortraitFixed={handleArtistPortraitFixed}
|
onArtistPortraitFixed={handleArtistPortraitFixed}
|
||||||
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
|
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
|
||||||
@@ -641,16 +698,45 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view.type === 'checkup' && (
|
{view.type === 'checkup' && (
|
||||||
|
isCurator ? (
|
||||||
<CheckupPage
|
<CheckupPage
|
||||||
onBack={() => setView({ type: 'timeline' })}
|
onBack={() => setView({ type: 'timeline' })}
|
||||||
onOpenPainting={handlePaintingClick}
|
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' && (
|
{view.type === 'timeline' && (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="site-dev-tools">
|
<div className="site-dev-tools">
|
||||||
|
{isCurator ? (
|
||||||
|
<>
|
||||||
|
<span className="curator-session-label" title={`Signed in as ${username}`}>
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
|
||||||
@@ -673,11 +759,30 @@ export default function HomePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="checkup-link-btn"
|
className="checkup-link-btn"
|
||||||
onClick={() => setView({ type: 'checkup' })}
|
onClick={openCheckup}
|
||||||
title="Open painting image checkup table"
|
title="Open painting image checkup table"
|
||||||
>
|
>
|
||||||
Checkup
|
Checkup
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<h1>Virtual Art Gallery</h1>
|
<h1>Virtual Art Gallery</h1>
|
||||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
<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
|
PUBLIC_URL=https://gallery.mysuperlab.netcraze.pro
|
||||||
TRUST_PROXY=true
|
TRUST_PROXY=true
|
||||||
IMAGE_DIR=/app/data/images
|
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
|
PUBLIC_URL: https://gallery.mysuperlab.netcraze.pro
|
||||||
TRUST_PROXY: "true"
|
TRUST_PROXY: "true"
|
||||||
IMAGE_DIR: /app/data/images
|
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:
|
volumes:
|
||||||
- /mnt/BasePool/Applications/Gallery/data/images:/app/data/images
|
- /mnt/BasePool/Applications/Gallery/data/images:/app/data/images
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
Generated
+118
@@ -9,9 +9,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"sharp": "^0.35.1"
|
"sharp": "^0.35.1"
|
||||||
},
|
},
|
||||||
@@ -614,6 +617,15 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -753,6 +765,18 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
@@ -974,6 +998,50 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -1414,6 +1482,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -1618,6 +1695,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -1671,6 +1757,26 @@
|
|||||||
"node": ">= 18"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -1970,6 +2076,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
|
|||||||
@@ -53,9 +53,12 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"sharp": "^0.35.1"
|
"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();
|
require('dotenv').config();
|
||||||
|
|
||||||
const pool = require('./db');
|
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 { 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');
|
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.set('trust proxy', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
app.use(express.json({ limit: '20mb' }));
|
app.use(express.json({ limit: '20mb' }));
|
||||||
|
app.use(createSessionMiddleware());
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/images', express.static(IMAGE_DIR));
|
app.use('/images', express.static(IMAGE_DIR));
|
||||||
|
|
||||||
const INFLUENCE_LINKS_EXISTS = `
|
const INFLUENCE_LINKS_EXISTS = `
|
||||||
@@ -297,7 +303,7 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update artist portrait checkup flags (checked / fixed)
|
// 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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const { checked, fixed } = req.body ?? {};
|
const { checked, fixed } = req.body ?? {};
|
||||||
@@ -349,6 +355,15 @@ app.patch('/api/artists/:id/checkup-flags', async (req, res) => {
|
|||||||
checked: !!result.rows[0].checked,
|
checked: !!result.rows[0].checked,
|
||||||
fixed: !!result.rows[0].fixed,
|
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) {
|
} catch (err) {
|
||||||
console.error('Artist checkup flags error:', err.message);
|
console.error('Artist checkup flags error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to update checkup flags' });
|
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
|
// 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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
|
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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]);
|
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
|
// 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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
|
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
|
||||||
@@ -411,13 +426,22 @@ app.post('/api/artists/:id/fix-portrait', async (req, res) => {
|
|||||||
[artistId]
|
[artistId]
|
||||||
);
|
);
|
||||||
res.json({ ...updated, fixed: true, checked: true });
|
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) {
|
} catch (err) {
|
||||||
console.error('Fix portrait error:', err.message);
|
console.error('Fix portrait error:', err.message);
|
||||||
res.status(500).json({ error: friendlyImageFetchError(err) });
|
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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const updated = await clearArtistPortrait(artistId);
|
const updated = await clearArtistPortrait(artistId);
|
||||||
@@ -426,13 +450,21 @@ app.post('/api/artists/:id/clear-portrait', async (req, res) => {
|
|||||||
[artistId]
|
[artistId]
|
||||||
);
|
);
|
||||||
res.json({ ...updated, fixed: true, checked: true });
|
res.json({ ...updated, fixed: true, checked: true });
|
||||||
|
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.curatorUser.id,
|
||||||
|
action: 'artist.clear_portrait',
|
||||||
|
resourceType: 'artist',
|
||||||
|
resourceId: artistId,
|
||||||
|
req,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clear portrait error:', err.message);
|
console.error('Clear portrait error:', err.message);
|
||||||
res.status(500).json({ error: err.message || 'Could not clear portrait' });
|
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 {
|
try {
|
||||||
const artistId = parseInt(req.params.id, 10);
|
const artistId = parseInt(req.params.id, 10);
|
||||||
const { imageData, mimeType } = req.body ?? {};
|
const { imageData, mimeType } = req.body ?? {};
|
||||||
@@ -457,6 +489,15 @@ app.post('/api/artists/:id/upload-portrait', async (req, res) => {
|
|||||||
[artistId]
|
[artistId]
|
||||||
);
|
);
|
||||||
res.json({ ...updated, fixed: true, checked: true });
|
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) {
|
} catch (err) {
|
||||||
console.error('Upload portrait error:', err.message);
|
console.error('Upload portrait error:', err.message);
|
||||||
res.status(500).json({ error: err.message || 'Could not upload portrait' });
|
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
|
// 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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path,
|
`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)
|
// 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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const { checked, fixed } = req.body ?? {};
|
const { checked, fixed } = req.body ?? {};
|
||||||
@@ -608,6 +649,15 @@ app.patch('/api/paintings/:id/checkup-flags', async (req, res) => {
|
|||||||
checked: !!result.rows[0].checked,
|
checked: !!result.rows[0].checked,
|
||||||
fixed: !!result.rows[0].fixed,
|
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) {
|
} catch (err) {
|
||||||
console.error('Checkup flags error:', err.message);
|
console.error('Checkup flags error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to update checkup flags' });
|
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
|
// 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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const result = await pool.query(
|
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
|
// 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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
|
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
|
||||||
@@ -736,13 +786,22 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => {
|
|||||||
[paintingId]
|
[paintingId]
|
||||||
);
|
);
|
||||||
res.json({ ...updated, fixed: true, checked: true });
|
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) {
|
} catch (err) {
|
||||||
console.error('Fix image error:', err.message);
|
console.error('Fix image error:', err.message);
|
||||||
res.status(500).json({ error: friendlyImageFetchError(err) });
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
if (!Number.isFinite(paintingId)) {
|
if (!Number.isFinite(paintingId)) {
|
||||||
@@ -750,6 +809,15 @@ app.delete('/api/paintings/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const removed = await deletePainting(paintingId);
|
const removed = await deletePainting(paintingId);
|
||||||
res.json(removed);
|
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) {
|
} catch (err) {
|
||||||
console.error('Delete painting error:', err.message);
|
console.error('Delete painting error:', err.message);
|
||||||
const status = err.message === 'Painting not found' ? 404 : 500;
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const updated = await clearPaintingImage(paintingId);
|
const updated = await clearPaintingImage(paintingId);
|
||||||
@@ -766,13 +834,21 @@ app.post('/api/paintings/:id/clear-image', async (req, res) => {
|
|||||||
[paintingId]
|
[paintingId]
|
||||||
);
|
);
|
||||||
res.json({ ...updated, fixed: true, checked: true });
|
res.json({ ...updated, fixed: true, checked: true });
|
||||||
|
|
||||||
|
await logCuratorAction({
|
||||||
|
userId: req.curatorUser.id,
|
||||||
|
action: 'painting.clear_image',
|
||||||
|
resourceType: 'painting',
|
||||||
|
resourceId: paintingId,
|
||||||
|
req,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clear image error:', err.message);
|
console.error('Clear image error:', err.message);
|
||||||
res.status(500).json({ error: err.message || 'Could not clear image' });
|
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 {
|
try {
|
||||||
const paintingId = parseInt(req.params.id, 10);
|
const paintingId = parseInt(req.params.id, 10);
|
||||||
const { imageData, mimeType } = req.body ?? {};
|
const { imageData, mimeType } = req.body ?? {};
|
||||||
@@ -797,6 +873,15 @@ app.post('/api/paintings/:id/upload-image', async (req, res) => {
|
|||||||
[paintingId]
|
[paintingId]
|
||||||
);
|
);
|
||||||
res.json({ ...updated, fixed: true, checked: true });
|
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) {
|
} catch (err) {
|
||||||
console.error('Upload image error:', err.message);
|
console.error('Upload image error:', err.message);
|
||||||
res.status(500).json({ error: err.message || 'Could not upload image' });
|
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)
|
// 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 {
|
try {
|
||||||
const imageUrl = req.query.url;
|
const imageUrl = req.query.url;
|
||||||
const searchUrl = req.query.searchUrl;
|
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-influence-sources.sql',
|
||||||
'migrate-painting-annotations.sql',
|
'migrate-painting-annotations.sql',
|
||||||
'migrate-artist-palette.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) {
|
async function applySqlFile(label, filePath) {
|
||||||
const sql = fs.readFileSync(filePath, 'utf8');
|
const sql = fs.readFileSync(filePath, 'utf8');
|
||||||
await pool.query(sql);
|
await pool.query(sql);
|
||||||
@@ -34,6 +58,9 @@ async function migrate() {
|
|||||||
await applySqlFile(file, filePath);
|
await applySqlFile(file, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Bootstrapping curator account (if needed) …');
|
||||||
|
await bootstrapCurator();
|
||||||
|
|
||||||
console.log('Database migration complete.');
|
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