# 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/` 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 artist’s 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": "", "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 artist’s paintings. - **Successors** — artists whose paintings were influenced by this artist’s 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 painting’s local image with **letterboxing** (`object-fit: contain`) so the full composition is visible. Click a thumbnail to open that work’s 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 painting’s 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": "", "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/` 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/` 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=…` |