Files
Art-gallery/Documentation/API.md
T
Danila KhodjaefandCursor bc8369e373 Add public curator notes and U-shaped hall wall hang.
Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:21:22 +03:00

912 lines
30 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`.
**Caching:** `/images` responses use `Cache-Control: public, max-age=0, must-revalidate` with `ETag` / `Last-Modified`. Painting and artist JSON payloads include optional **`image_cache_key`** / **`thumbnail_cache_key`** (and **`portrait_cache_key`** / **`portrait_thumb_cache_key`** on artists) — Unix ms from the files `mtime` on disk. The client appends `?v=<key>` to image URLs so fix/upload/clear updates show immediately after reload even when the relative path is unchanged.
**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/paintings/:id/curator-notes` | `painting.update_curator_notes` |
| `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.
---
## Locale (`?locale=ru`)
Public catalog endpoints accept optional **`locale`** query param (`en` default, `ru` supported) or `Accept-Language: ru`.
Affected routes: `/api/catalog/bootstrap`, `/api/timeline`, `/api/search`, `/api/artists`, `/api/artists/:id`, `/api/paintings/:id`, `/api/movements/:id/gallery`, `/api/movements/:id/artists`, `/api/artists/:id/navigation`.
Responses include `"locale": "ru"` when resolved. Display field names are unchanged; values come from `entity_translations` when `status = published`, else canonical English.
Full guide: [i18n-russian.md](i18n-russian.md).
---
## Translations (curator)
Requires curator session. Base path: `/api/translations`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/translations/coverage?locale=ru` | Coverage stats |
| `GET` | `/api/translations/worklist/:entityType?locale=ru` | Artists/paintings/movements with translation status |
| `GET` | `/api/translations/:entityType/:id` | Canonical + all translation rows |
| `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` |
| `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale |
## Influences (curator)
Requires curator session. Base path: `/api/influences`. See [influence-import.md](influence-import.md).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/influences` | List edges (`artistId`, `paintingId`, `q`, pagination) |
| `GET` | `/api/influences/graph?artistId=` | Neighborhood nodes/edges for visualization |
| `POST` | `/api/influences` | Create edge `{ paintingId, sourceType, sourceArtistId\|sourcePaintingId\|sourceMovementId, … }` |
| `PATCH` | `/api/influences/:id` | Update notes / remap source |
| `DELETE` | `/api/influences/:id` | Delete edge |
| `POST` | `/api/influences/import/parse` | Parse upload `{ filename, contentBase64, sheet? }` — returns `contentHash` / `payloadHash` / `alreadyImported` |
| `POST` | `/api/influences/import/preview` | Validate `{ rows, mapping, contentHash?, payloadHash? }` |
| `POST` | `/api/influences/import/commit` | Insert `{ proposals, contentHash?, payloadHash?, force? }``409` if duplicate unless `force` |
Public painting detail still exposes read-only `influencedBy` / `influenced` (unchanged).
---
## Tours
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `GET` | `/api/tours` | public | Published tour summaries (`id`, `title`, `description`, cover thumb, `stopCount`) |
| `GET` | `/api/tours/:id` | public* | Full tour + ordered `paintings` + `stopBodies` (*draft tours require curator session) |
| `GET` | `/api/tours/admin` | curator | All tours (any status) |
| `POST` | `/api/tours` | curator | Create `{ title, description?, status? }` |
| `PATCH` | `/api/tours/:id` | curator | Update title / description / status / cover |
| `DELETE` | `/api/tours/:id` | curator | Delete tour + stops |
| `PUT` | `/api/tours/:id/stops` | curator | Replace ordered stops `{ stops: [{ paintingId, body }] }` |
Detail response shape:
```json
{
"tour": { "id": 1, "title": "…", "status": "published", "stopCount": 5 },
"paintings": [ /* Painting rows in stop order */ ],
"stopBodies": { "42": "Tour text for this stop…" }
}
```
---
## `GET /api/search`
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
**Query**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
| `limit` | int | 20 | Max results total (capped at **50**) |
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` |
| `locale` | string | `en` | `ru` — search and return published Russian aliases when available |
**Matching (case-insensitive `ILIKE`):**
| Entity | Fields |
|--------|--------|
| Artist | `name`, `wikipedia_title`, movement name |
| Movement | movement `name`, era name |
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement name |
| All (when `locale=ru`) | Published rows in `entity_translations` for `name` / `title` |
Prefix matches on primary labels (`name` / `title`) rank before substring matches.
**Response**
```json
{
"q": "monet",
"results": [
{
"type": "artist",
"id": 19,
"name": "Claude Monet",
"birth_year": 1840,
"death_year": 1926,
"movement_name": "Impressionism",
"portrait_path": "portraits/Claude_Monet.jpg",
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg"
},
{
"type": "movement",
"id": 12,
"name": "Impressionism",
"color": "#87CEEB",
"start_year": 1860,
"end_year": 1890,
"era_name": "Modern"
},
{
"type": "painting",
"id": 241,
"title": "Water Lilies",
"year": 1919,
"artist_id": 19,
"artist_name": "Claude Monet",
"movement_name": "Impressionism",
"thumbnail_path": "paintings/thumbs/Claude_Monet_Water_Lilies_thumb.jpg",
"image_path": "paintings/Claude_Monet_Water_Lilies.jpg"
}
]
}
```
**Indexes:** applied by `npm run dev:migrate` (`db/migrate-search.sql`) or standalone `npm run dev:migrate:search`.
**Client:** `api.search(q, { limit?, types? })`.
**Navigation from search:** choosing a **painting** opens detail with `returnTo: timeline`; the client shows **← Back to Timeline** and calls `goToTimelineHome()` (clears gallery session, resets timeline zoom). Choosing an **artist** or **movement** uses the normal gallery entry handlers.
---
## `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 dev: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 dev:import-painter-palette`, **`palette_metadata`** holds PainterPalette enrichment (nationality, styles, occupations, raw influence fields, etc.). Run `npm run dev:migrate:artist-checkup-flags` and `npm run dev: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": "paintings/Artist_Title.jpg",
"thumbnail_path": "paintings/thumbs/Artist_Title_thumb.jpg",
"image_cache_key": 1739123456789,
"thumbnail_cache_key": 1739123456790,
"checkup_checked": false,
"checkup_fixed": false,
"curator_notes": "",
"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/curator-notes`
Update public curator notes for a painting.
**Body**
```json
{ "curatorNotes": "Optional editorial text…" }
```
`curatorNotes` must be a string (trim applied; empty string clears the notes).
**Response:** `{ "curatorNotes": "…" }`
**Audit:** `painting.update_curator_notes`
### `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",
"image_cache_key": 1739123456789,
"thumbnail_cache_key": 1739123456790,
"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`, `image_cache_key`, `thumbnail_cache_key`, `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.search(q, options?)` | `GET /api/search` |
| `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, revision?)` | `/images/<path>` or placeholder; optional `?v=` cache buster |
| `paintingImageRevision(painting, sessionRevision?)` | Prefer API `image_cache_key` / `thumbnail_cache_key`, else in-session counter |
| `galleryImageUrl(painting, sessionRevision?)` | Local thumb/full only (3D); auto `?v=` from cache keys |
| `galleryImageUrlWithRevision(painting, sessionRevision?)` | Alias of `galleryImageUrl` |
| `paintingImageUrl(painting, sessionRevision?)` | Local file, on-demand API, or `null` when cleared (`checkup_fixed` + no paths) |
| `portraitUrl(path, revision?, artist?)` | `/images/<path>` with `?v=` from revision or artist cache keys |
| `validateDebugUploadFile(file)` | Client-side size/type check before base64 upload |
| `api.getPaintingCheckup()` | `GET /api/paintings/checkup` |
| `api.updatePaintingCheckupFlags(id, flags)` | `PATCH /api/paintings/:id/checkup-flags` |
| `api.updatePaintingCuratorNotes(id, curatorNotes)` | `PATCH /api/paintings/:id/curator-notes` |
| `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=…` |