Files
Art-gallery/Documentation/API.md
T
Danila Khodjaef 99b8559607 Speed up timeline load with portrait thumbs, bootstrap API, and caching.
Add catalog bootstrap endpoint, portrait thumbnail pipeline, lazy queued timeline images, gzip compression, and 3D texture throttling with code-split VirtualGallery.
2026-07-06 14:27:29 +03:00

734 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Art Gallery — REST API
Base URL (paths are the same on every host; only the origin changes):
| Context | Base URL |
|---------|----------|
| **Production (public)** | `https://gallery.mysuperlab.netcraze.pro` |
| **Production (LAN)** | `http://192.168.10.122:5173` |
| **Development (public)** | `https://devgallery.mysuperlab.netcraze.pro` |
| **Development (LAN)** | `http://192.168.10.70:5173` |
| **Local Vite proxy** | `http://localhost:5173` (proxies `/api` and `/images` to API on `:3451`) |
| **Local API only** | `http://localhost:3451` (when using `npm run dev:web`) |
See [environments.md](environments.md) for Keenetic rules, databases, and deploy.
All JSON responses use `Content-Type: application/json`. Errors return `{ "error": "message" }` with an appropriate HTTP status.
Static images are served at `/images/<relative-path>` from `IMAGE_DIR`.
**Quick check:**
```powershell
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
```
---
## 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/catalog/bootstrap`
**Preferred for timeline first paint.** Returns bounds, eras, movements, and slim artist rows in a single response (replaces the separate `bounds` + `timeline` + `artists?timeline=1` waterfall).
**Query**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `start` | int | bounds `min_year` | Window start year |
| `end` | int | bounds `max_year` | Window end year |
**Response**
```json
{
"bounds": { "min_year": -800, "max_year": 2100 },
"eras": [ ],
"movements": [ ],
"artists": [
{
"id": 1,
"name": "Claude Monet",
"birth_year": 1840,
"death_year": 1926,
"movement_id": 12,
"portrait_path": "portraits/Claude_Monet.jpg",
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg",
"wikipedia_title": "Claude Monet",
"century": 19,
"movement_name": "Impressionism",
"movement_color": "#6B8E9F"
}
]
}
```
**Caching:** `Cache-Control: public, max-age=300` with `ETag` (304 when catalog row counts unchanged).
The React home page loads this endpoint **once** on mount. Pan and zoom filter movements and portraits **client-side** — no refetch per view change.
---
## `GET /api/bounds`
Returns the overall timeline year range used to initialise the zoomable timeline.
**Response**
```json
{
"min_year": -800,
"max_year": 2100
}
```
`min_year` is the earliest art movement start; `max_year` is the latest of era ends, movement ends, and artist death years.
---
## `GET /api/timeline`
Historical eras and art movements overlapping a year window.
**Query**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `start` | int | -3000 | Window start year |
| `end` | int | 2100 | Window end year |
**Response**
```json
{
"eras": [ { "id": 1, "name": "Renaissance", "start_year": 1300, "end_year": 1600, "start_definite": false, "end_definite": false, "description": "...", "sort_order": 3 } ],
"movements": [ { "id": 5, "name": "Impressionism", "start_year": 1860, "end_year": 1890, "era_id": 6, "era_name": "Modern", "color": "#87CEEB", ... } ]
}
```
Movements are filtered to those with at least one artist active in the requested range.
---
## `GET /api/artists`
Artists for timeline portraits and the movement flow diagram.
**Query**
| Param | Type | Description |
|-------|------|-------------|
| `start` | int | Only artists alive after this year |
| `end` | int | Only artists born before this year |
| `movement_id` | int | Filter by movement |
| `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_short`, `bio_full`; includes `portrait_thumb_path`) |
**Response** — array of artist objects with joined `movement_name` and `movement_color`.
The React home page loads the timeline catalog **once** on mount via `GET /api/catalog/bootstrap` (or legacy: `GET /api/bounds` + `GET /api/timeline` + `GET /api/artists?timeline=1`). Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame).
---
## `GET /api/movements/:id/artists`
Artists belonging to a single movement.
**Response** — array of `{ id, name, birth_year, death_year, portrait_path, bio_short }`.
---
## `GET /api/movements/:id/gallery`
Full **movement gallery** payload for the 3D movement wings view (opened by clicking a movement name on the home page).
**Response**
```json
{
"movement": {
"id": 34,
"name": "Baroque",
"start_year": 1600,
"end_year": 1750,
"era_id": 6,
"era_name": "Baroque",
"color": "#8B4513",
...
},
"paintings": [
{
"id": 120,
"title": "...",
"year": 1640,
"artist_id": 5,
"artist_name": "Rembrandt",
"image_path": "paintings/...",
"thumbnail_path": "paintings/thumbs/...",
"checkup_checked": false,
"checkup_fixed": false,
"has_influence_links": true,
...
}
]
}
```
| Field | Meaning |
|-------|---------|
| `movement.era_name` | Joined from `historical_eras` — used to pick period interior styling |
| `paintings[].artist_name` | Artist display name for frame captions (`year · artist`) |
| `paintings` order | Chronological: `year`, then `sort_order`, artist birth year, title |
Includes all paintings whose `artist.movement_id` matches `:id`. Returns **404** if the movement does not exist.
**Client:** `api.getMovementGallery(id)` in `client/src/api/client.ts`; rendered by `VirtualGallery` in `mode: 'movement'`.
---
## `GET /api/artists/:id`
Full artist profile for the bio page and 3D gallery entry.
**Response**
```json
{
"artist": {
"id": 1,
"name": "...",
"birth_year": 1840,
"death_year": 1926,
"movement_name": "...",
"portrait_path": "portraits/Claude_Monet.jpg",
"bio_short": "First two sentences from Wikipedia…",
"bio_full": "Full Wikipedia lead section…",
"wikipedia_title": "Claude Monet",
"checkup_checked": false,
"checkup_fixed": false,
"palette_metadata": { "nationality": "French", "styles": "Impressionism, …", "source": "PainterPalette.csv", ... }
},
"periods": [ { "id": 1, "name": "Milan Period", "start_year": 1482, "end_year": 1499, ... } ],
"paintings": [ { "id": 10, "title": "...", "year": 1498, "image_path": "...", "thumbnail_path": "...", "wikipedia_title": "...", "has_influence_links": true, "checkup_checked": false, "checkup_fixed": false, ... } ]
}
```
Each painting includes:
| Field | Meaning |
|-------|---------|
| `has_influence_links` | `true` when the work appears in any influence row — 3D gallery shows a golden lamp above the frame |
| `checkup_checked` | Reviewed in checkup / debug workflow (gold frame in 3D when true) |
| `checkup_fixed` | Image replaced via **Fix it** |
Populate biographies with `npm run fetch-artist-bios` (see [data-and-images.md](data-and-images.md)).
Artist objects also include `checkup_checked` and `checkup_fixed` (same semantics as paintings; gold portrait border when reviewed). After `npm run import-painter-palette`, **`palette_metadata`** holds PainterPalette enrichment (nationality, styles, occupations, raw influence fields, etc.). Run `npm run migrate:artist-checkup-flags` and `npm run migrate:artist-palette` on existing databases.
---
## `PATCH /api/artists/:id/checkup-flags`
Update artist portrait review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
Same rules as painting checkup flags: setting `fixed: true` also sets `checked: true`.
**Response**
```json
{ "checked": true, "fixed": false }
```
---
## `GET /api/artists/:id/debug-portrait-search`
Portrait image search for debug mode on the artist bio page (Custom Search → Google Arts & Culture → scrape → DuckDuckGo).
**Response** — same shape as painting debug search (`query`, `imageUrl`, `searchUrl`, `source`, optional `thumbUrl`, `sourceLabel`).
---
## `GET /api/artists/:id/debug-portrait-search/more`
Up to 20 ranked portrait candidates for the **More** picker modal.
**Query:** `limit` (int, default 20, max 20)
**Response**
```json
{
"query": "Leonardo da Vinci portrait",
"searchUrl": "https://…",
"source": "google-custom-search",
"results": [
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-custom-search", "width": 1200, "height": 1600 }
]
}
```
Each result may include optional `width` and `height` (pixels). The **More** modal shows these under each thumbnail; when missing, the client probes dimensions via `GET /api/debug/image-proxy`.
---
## `POST /api/artists/:id/fix-portrait`
Download a remote URL and replace the artists local portrait. Sets `checkup_fixed = true` and `checkup_checked = true`.
**Body** — same as `POST /api/paintings/:id/fix-image` (`imageUrl` required; optional `searchUrl`, `source`, `thumbUrl`).
**Response**
```json
{
"portraitPath": "portraits/Leonardo_da_Vinci.jpg",
"fixed": true,
"checked": true
}
```
---
## `POST /api/artists/:id/clear-portrait`
Delete the portrait file from disk, set `portrait_path = NULL`, and set both checkup flags. Used by debug **Clear**; the bio page shows an empty portrait slot (no placeholder).
**Response**
```json
{
"portraitPath": null,
"fixed": true,
"checked": true
}
```
---
## `POST /api/artists/:id/upload-portrait`
Upload a local image (base64 JSON body). Validates with `sharp`, resizes to portrait dimensions, sets checkup flags.
**Body**
```json
{
"imageData": "<base64>",
"mimeType": "image/jpeg"
}
```
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same as `fix-portrait`.
---
## `GET /api/artists/:id/navigation`
Related artists for hall-to-hall navigation at the exit doorway. Derived from **`painting_influence_sources`**:
- **Predecessors** — artists whose works (or artist records) influenced this artists paintings.
- **Successors** — artists whose paintings were influenced by this artists works (via painting sources), or whose paintings cite this artist as a source (via artist sources).
Both lists are grouped by art movement and exclude the current artist. Each artist appears once even when multiple influence edges exist.
**Response**
```json
{
"predecessors": [
{
"movement_id": 4,
"movement_name": "Early Renaissance",
"movement_color": "#B8860B",
"artists": [
{
"id": 12,
"name": "Domenico Ghirlandaio",
"birth_year": 1448,
"death_year": 1494,
"portrait_path": "portraits/Domenico_Ghirlandaio.jpg"
}
]
}
],
"successors": []
}
```
---
## `POST /api/artists/:id/preload-images`
**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**
```json
{ "fetched": 12, "total": 15 }
```
`fetched` counts paintings with a resolvable local image after the scan.
---
## `GET /api/paintings/:id`
Painting detail with influence graph neighbours.
**Response**
```json
{
"painting": { "id": 10, "title": "...", "artist_name": "...", "image_path": "...", "checkup_checked": false, "checkup_fixed": false, "has_influence_links": true, ... },
"influencedBy": [
{
"source_type": "painting",
"id": 3, "title": "...", "artist_name": "...", "year": 1885,
"notes": "...", "aspects": "...", "quote": "...", "source_author": "...", "source_url": "..."
},
{
"source_type": "artist",
"source_artist_id": 12, "source_artist_name": "Paul Cézanne",
"artist_portrait": "...", "period_note": "during creation (1907)",
"period_start_year": 1907, "period_end_year": 1907
},
{
"source_type": "movement",
"movement_id": 8, "movement_name": "Fauvism", "movement_color": "#c45c26",
"period_start_year": 1905, "period_end_year": 1907
}
],
"influenced": [ { "source_type": "painting", "id": 20, "title": "...", ... } ],
"annotations": [
{
"id": 1,
"label": "Central figure",
"body": "Short art-history note…",
"category": "subject",
"pos_x": 42.5,
"pos_y": 38.0,
"source_author": "E.H. Gombrich",
"source": "The Story of Art",
"source_url": "https://…",
"sort_order": 0,
"confidence": "curated"
}
]
}
```
`painting.has_influence_links` matches the flag on `GET /api/artists/:id` paintings (see above).
Both **`influencedBy`** and **`influenced`** are read from **`painting_influence_sources`** only (one row per edge). Painting-type sources appear in *Influenced By* when they are predecessors; in *Influenced* when this painting is the source of a later work. Artist and movement sources appear only in *Influenced By*. The legacy `painting_influences` table is not merged into these responses — it mirrors painting edges for scripts only.
In the UI, side-panel thumbnails use the paintings local image with **letterboxing** (`object-fit: contain`) so the full composition is visible. Click a thumbnail to open that works detail view.
---
## `GET /api/paintings/:id/image`
Serves a painting image file. Resolves and caches from external sources only when no local file exists.
**Query**
| Param | Values | Default |
|-------|--------|---------|
| `size` | `thumb`, `full` | `thumb` |
Returns the image bytes with `Cache-Control: public, max-age=86400`, or `404` if unavailable.
---
## Developer image audit
**Curator login required** for every route in this section. See [Authentication](#authentication) above.
Routes for the **Checkup** page and **Debug mode** on painting detail and artist bio. Register `GET /api/paintings/checkup` **before** `GET /api/paintings/:id` so `"checkup"` is not parsed as a painting id.
### `GET /api/paintings/checkup`
Full-catalog audit table for the Checkup UI.
**Response**
```json
{
"paintings": [
{
"id": 36,
"title": "Trinity",
"artist_name": "Andrei Rublev",
"year": 1411,
"gallery_file": "paintings/Andrei_Rublev_Trinity.jpg",
"gallery_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
"detail_file": "paintings/Andrei_Rublev_Trinity.jpg",
"detail_preview": "paintings/thumbs/Andrei_Rublev_Trinity_thumb.jpg",
"detail_on_demand": false,
"gallery_file_exists": true,
"detail_file_exists": true,
"checked": false,
"fixed": false
}
],
"total": 1201
}
```
| Field | Meaning |
|-------|---------|
| `checked` | Reviewed in checkup workflow (`checkup_checked`) |
| `fixed` | Image replaced via Fix (`checkup_fixed`) |
| `detail_on_demand` | `true` when detail would use `GET /api/paintings/:id/image?size=full` |
| `gallery_file_exists` / `detail_file_exists` | Disk check under `IMAGE_DIR` |
---
### `PATCH /api/paintings/:id/checkup-flags`
Update review flags. Body: `{ "checked"?: boolean, "fixed"?: boolean }` — at least one field required.
Setting `fixed: true` also sets `checked: true`. While `fixed` is true, the API rejects clearing `checked`.
**Response**
```json
{ "checked": true, "fixed": false }
```
---
### `GET /api/paintings/:id/debug-image-search`
Google-family image search for debug / checkup (Custom Search → Google Arts & Culture → scrape → DuckDuckGo). Used by **Search visible** on the Checkup page and the debug panel on painting detail.
**Response**
```json
{
"query": "Andrei Rublev Trinity painting",
"imageUrl": "https://…",
"source": "google-arts",
"pageUrl": "https://…"
}
```
---
### `POST /api/paintings/:id/fix-image`
Download a remote URL and replace the paintings local full image + thumbnail. Sets `checkup_fixed = true` and `checkup_checked = true`.
Uses `downloadImageForFix` in `scripts/image-fetcher.js` (browser User-Agent, referer fallbacks, Wikimedia URL upgrades) for reliable downloads from Google Arts, Commons, etc.
**Body**
```json
{
"imageUrl": "https://…",
"searchUrl": "https://…",
"source": "google-arts",
"thumbUrl": "https://…"
}
```
Only `imageUrl` is required; optional fields improve fetch success for hotlinked URLs.
**Response**
```json
{
"imagePath": "paintings/Artist_Title.jpg",
"thumbnailPath": "paintings/thumbs/Artist_Title_thumb.jpg",
"fixed": true,
"checked": true
}
```
---
### `GET /api/paintings/:id/debug-image-search/more`
Up to 20 ranked painting image candidates for the **More** picker modal.
**Query:** `limit` (int, default 20, max 20)
**Response**
```json
{
"query": "Andrei Rublev Trinity painting",
"searchUrl": "https://…",
"source": "google-arts",
"results": [
{ "imageUrl": "https://…", "thumbUrl": "https://…", "source": "google-arts", "width": 2400, "height": 1800 }
]
}
```
Optional `width` / `height` on each result — see portrait **more** endpoint above.
---
### `POST /api/paintings/:id/clear-image`
Delete full + thumbnail files from disk, set `image_path` and `thumbnail_path` to `NULL`, and set both checkup flags. Used by debug **Clear**; detail view shows an empty frame (no placeholder, no on-demand refetch).
**Response**
```json
{
"imagePath": null,
"thumbnailPath": null,
"fixed": true,
"checked": true
}
```
---
### `POST /api/paintings/:id/upload-image`
Upload a local painting image (base64 JSON body). Validates with `sharp`, writes full file, regenerates thumbnail, sets checkup flags.
**Body**
```json
{
"imageData": "<base64>",
"mimeType": "image/jpeg"
}
```
Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — same shape as `fix-image` (`imagePath`, `thumbnailPath`, `fixed`, `checked`).
---
### `DELETE /api/paintings/:id`
Permanently remove a painting (debug **Remove entry** on painting detail). Deletes full + thumbnail files from disk, then deletes the `paintings` row. Related rows in `painting_influence_sources`, `painting_annotations`, and legacy `painting_influences` are removed by `ON DELETE CASCADE`.
**Response**
```json
{
"id": 42,
"artistId": 7,
"title": "Example Work"
}
```
**Errors:** `404` if the painting does not exist.
The client refetches the artist (and movement gallery when applicable), remounts the 3D hall, and opens the next or previous work in the catalog — or returns to the gallery if it was the last work.
---
### `GET /api/debug/image-proxy`
Proxy a remote image URL for debug preview (avoids hotlink / CORS blocks in the browser).
**Query:** `url` — must be `http://` or `https://`
Returns image bytes with appropriate `Content-Type`.
---
## Frontend helpers
The React client wraps these endpoints in `client/src/api/client.ts`. All requests send `credentials: 'include'` for session cookies.
| Function | Maps to |
|----------|---------|
| `getAuthMe()` | `GET /api/auth/me` |
| `loginCurator(user, pass)` | `POST /api/auth/login` |
| `logoutCurator()` | `POST /api/auth/logout` |
| `api.getBounds()` | `GET /api/bounds` |
| `api.getTimeline(start, end)` | `GET /api/timeline` |
| `api.getArtists(...)` | `GET /api/artists` |
| `api.getTimelineArtists()` | `GET /api/artists?timeline=1` |
| `api.getArtist(id)` | `GET /api/artists/:id` |
| `api.getArtistNavigation(id)` | `GET /api/artists/:id/navigation` |
| `api.getPainting(id)` | `GET /api/paintings/:id` |
| `preloadArtistImages(id)` | `POST /api/artists/:id/preload-images` |
| `imageUrl(path)` | `/images/<path>` or placeholder |
| `galleryImageUrl(painting)` | Local thumb/full only (3D) |
| `galleryImageUrlWithRevision(painting, revision)` | Local URL with `?v=` cache buster after fix |
| `paintingImageUrl(painting)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
| `portraitUrl(path, revision?)` | `/images/<path>` with optional `?v=` cache buster |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.getPaintingDebugImageSearch(id)` | `GET /api/paintings/:id/debug-image-search` |
| `api.getPaintingDebugImageSearchMore(id, limit?)` | `GET /api/paintings/:id/debug-image-search/more` |
| `api.fixPaintingImage(id, imageUrl, context?)` | `POST /api/paintings/:id/fix-image` |
| `api.clearPaintingImage(id)` | `POST /api/paintings/:id/clear-image` |
| `api.deletePainting(id)` | `DELETE /api/paintings/:id` |
| `api.uploadPaintingImage(id, file)` | `POST /api/paintings/:id/upload-image` |
| `api.updateArtistCheckupFlags(id, flags)` | `PATCH /api/artists/:id/checkup-flags` |
| `api.getArtistDebugPortraitSearch(id)` | `GET /api/artists/:id/debug-portrait-search` |
| `api.getArtistDebugPortraitSearchMore(id, limit?)` | `GET /api/artists/:id/debug-portrait-search/more` |
| `api.fixArtistPortrait(id, imageUrl, context?)` | `POST /api/artists/:id/fix-portrait` |
| `api.clearArtistPortrait(id)` | `POST /api/artists/:id/clear-portrait` |
| `api.uploadArtistPortrait(id, file)` | `POST /api/artists/:id/upload-portrait` |
| `debugImageProxyUrl(imageUrl, context?)` | `GET /api/debug/image-proxy?url=…` |