Files
Art-gallery/server/index.js
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

1054 lines
35 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const compression = require('compression');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
const pool = require('./db');
const { createSessionMiddleware } = require('./middleware/session');
const { requireCurator } = require('./middleware/auth');
const { logCuratorAction } = require('./audit-log');
const authRoutes = require('./routes/auth');
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service');
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
const app = express();
const HOST = process.env.HOST || '0.0.0.0';
const PORT = Number(process.env.PORT) || 3001;
const PUBLIC_URL = process.env.PUBLIC_URL || '';
if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
app.use(cors({ origin: true, credentials: true }));
app.use(compression());
app.use(express.json({ limit: '20mb' }));
app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes);
app.use(
'/images',
express.static(IMAGE_DIR, {
maxAge: '365d',
immutable: true,
setHeaders(res) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
},
})
);
const TIMELINE_ARTIST_SELECT = `
SELECT a.id, a.name, a.birth_year, a.death_year, a.movement_id, a.portrait_path,
a.portrait_thumb_path, a.wikipedia_title, a.century,
m.name as movement_name, m.color as movement_color
FROM artists a
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE 1=1`;
async function fetchCatalogBounds() {
const result = await pool.query(`
SELECT
(SELECT MIN(start_year) FROM art_movements) as min_year,
GREATEST(
(SELECT MAX(end_year) FROM historical_eras),
(SELECT MAX(end_year) FROM art_movements),
(SELECT MAX(death_year) FROM artists WHERE death_year IS NOT NULL)
) as max_year
`);
return result.rows[0];
}
async function fetchTimelineErasAndMovements(startYear, endYear) {
const [eras, movements] = await Promise.all([
pool.query(
`SELECT * FROM historical_eras
WHERE end_year >= $1 AND start_year <= $2
ORDER BY sort_order, start_year`,
[startYear, endYear]
),
pool.query(
`SELECT DISTINCT m.*, e.name as era_name
FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id
INNER JOIN artists a ON a.movement_id = m.id
WHERE m.end_year >= $1 AND m.start_year <= $2
AND (a.death_year IS NULL OR a.death_year >= $1)
AND (a.birth_year IS NULL OR a.birth_year <= $2)
ORDER BY m.start_year`,
[startYear, endYear]
),
]);
return { eras: eras.rows, movements: movements.rows };
}
async function fetchTimelineArtists(startYear, endYear) {
let query = TIMELINE_ARTIST_SELECT;
const params = [];
if (startYear != null) {
params.push(startYear);
query += ` AND (a.death_year IS NULL OR a.death_year >= $${params.length})`;
}
if (endYear != null) {
params.push(endYear);
query += ` AND (a.birth_year IS NULL OR a.birth_year <= $${params.length})`;
}
query += ' ORDER BY a.birth_year';
const result = await pool.query(query, params);
return result.rows;
}
async function catalogBootstrapEtag() {
const result = await pool.query(`
SELECT
(SELECT COUNT(*)::int FROM artists) AS artist_count,
(SELECT COUNT(*)::int FROM art_movements) AS movement_count,
(SELECT COUNT(*)::int FROM historical_eras) AS era_count,
(SELECT COUNT(*)::int FROM paintings) AS painting_count
`);
return result.rows[0];
}
function sendCatalogCacheHeaders(res, etagSource) {
const etag = `"${crypto.createHash('md5').update(JSON.stringify(etagSource)).digest('hex')}"`;
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('ETag', etag);
return etag;
}
const INFLUENCE_LINKS_EXISTS = `
EXISTS (
SELECT 1 FROM painting_influence_sources pis
WHERE pis.painting_id = p.id OR pis.source_painting_id = p.id
)`;
const INFLUENCED_BY_SQL = `
SELECT
pis.source_type,
pis.period_note,
pis.period_start_year,
pis.period_end_year,
pis.notes,
pis.source,
pis.aspects,
pis.quote,
pis.source_author,
pis.source_url,
pis.confidence,
pis.discovered_via,
p.id,
p.title,
p.year,
p.image_path,
pa.name AS artist_name,
pa.id AS artist_id,
sa.id AS source_artist_id,
sa.name AS source_artist_name,
sa.portrait_path AS artist_portrait,
m.id AS movement_id,
m.name AS movement_name,
m.color AS movement_color
FROM painting_influence_sources pis
LEFT JOIN paintings p ON pis.source_painting_id = p.id
LEFT JOIN artists pa ON p.artist_id = pa.id
LEFT JOIN artists sa ON pis.source_artist_id = sa.id
LEFT JOIN art_movements m ON pis.source_movement_id = m.id
WHERE pis.painting_id = $1
ORDER BY
CASE pis.source_type WHEN 'painting' THEN 1 WHEN 'artist' THEN 2 ELSE 3 END,
COALESCE(p.year, pis.period_start_year) NULLS LAST`;
const INFLUENCED_SQL = `
SELECT
pis.notes,
pis.source,
pis.aspects,
pis.quote,
pis.source_author,
pis.source_url,
'painting' AS source_type,
p.id,
p.title,
p.year,
p.image_path,
a.name AS artist_name,
a.id AS artist_id
FROM painting_influence_sources pis
JOIN paintings p ON pis.painting_id = p.id
JOIN artists a ON p.artist_id = a.id
WHERE pis.source_painting_id = $1 AND pis.source_type = 'painting'
ORDER BY p.year NULLS LAST, p.title`;
app.get('/api/timeline', async (req, res) => {
try {
const { start, end } = req.query;
const startYear = parseInt(start) || -3000;
const endYear = parseInt(end) || 2100;
const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear);
res.json({ eras, movements });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch timeline' });
}
});
// Single request for timeline first paint: bounds + eras + movements + slim artists
app.get('/api/catalog/bootstrap', async (req, res) => {
try {
const bounds = await fetchCatalogBounds();
const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000;
const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100;
const etagSource = await catalogBootstrapEtag();
const etag = sendCatalogCacheHeaders(res, etagSource);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
const [{ eras, movements }, artists] = await Promise.all([
fetchTimelineErasAndMovements(startYear, endYear),
fetchTimelineArtists(startYear, endYear),
]);
res.json({ bounds, eras, movements, artists });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch catalog bootstrap' });
}
});
// Movement gallery — all paintings by artists in the movement, chronological
app.get('/api/movements/:id/gallery', async (req, res) => {
try {
const { id } = req.params;
const movement = await pool.query(
`SELECT m.*, e.name AS era_name
FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id
WHERE m.id = $1`,
[id]
);
if (movement.rows.length === 0) {
return res.status(404).json({ error: 'Movement not found' });
}
const paintings = await pool.query(
`SELECT p.*,
a.name AS artist_name,
p.checkup_checked,
p.checkup_fixed,
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
FROM paintings p
INNER JOIN artists a ON p.artist_id = a.id
WHERE a.movement_id = $1
ORDER BY p.year NULLS LAST, p.sort_order, a.birth_year NULLS LAST, p.title`,
[id]
);
res.json({
movement: movement.rows[0],
paintings: paintings.rows,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch movement gallery' });
}
});
// Artists for a movement in a time range
app.get('/api/movements/:id/artists', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT id, name, birth_year, death_year, portrait_path, bio_short
FROM artists WHERE movement_id = $1
ORDER BY birth_year`,
[id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch artists' });
}
});
// All artists in a year range (for timeline portrait placement)
app.get('/api/artists', async (req, res) => {
try {
const { start, end, movement_id, timeline } = req.query;
const timelineOnly = timeline === '1' || timeline === 'true';
let query = timelineOnly
? TIMELINE_ARTIST_SELECT
: `
SELECT a.*, m.name as movement_name, m.color as movement_color
FROM artists a
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE 1=1`;
const params = [];
if (start) {
params.push(parseInt(start));
query += ` AND (a.death_year IS NULL OR a.death_year >= $${params.length})`;
}
if (end) {
params.push(parseInt(end));
query += ` AND (a.birth_year IS NULL OR a.birth_year <= $${params.length})`;
}
if (movement_id) {
params.push(parseInt(movement_id));
query += ` AND a.movement_id = $${params.length}`;
}
query += ' ORDER BY a.birth_year';
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch artists' });
}
});
// Artist detail with periods and paintings
app.get('/api/artists/:id/navigation', async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const groupByMovement = (rows) => {
const map = new Map();
for (const row of rows) {
const key = row.movement_id ?? 0;
if (!map.has(key)) {
map.set(key, {
movement_id: row.movement_id,
movement_name: row.movement_name || 'Other',
movement_color: row.movement_color || '#8B7355',
artists: [],
});
}
const group = map.get(key);
if (!group.artists.some((a) => a.id === row.id)) {
group.artists.push({
id: row.id,
name: row.name,
birth_year: row.birth_year,
death_year: row.death_year,
portrait_path: row.portrait_path,
});
}
}
return Array.from(map.values()).sort((a, b) =>
a.movement_name.localeCompare(b.movement_name)
);
};
const predecessorSql = `
SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
FROM paintings p_work
JOIN painting_influence_sources pis ON pis.painting_id = p_work.id
LEFT JOIN paintings p_pred ON pis.source_painting_id = p_pred.id
LEFT JOIN artists a ON a.id = COALESCE(p_pred.artist_id, pis.source_artist_id)
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE p_work.artist_id = $1 AND a.id IS NOT NULL AND a.id <> $1`;
const successorSql = `
SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
FROM painting_influence_sources pis
JOIN paintings p_src ON pis.source_painting_id = p_src.id
JOIN paintings p_work ON pis.painting_id = p_work.id
JOIN artists a ON p_work.artist_id = a.id
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE p_src.artist_id = $1 AND a.id <> $1
UNION
SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
FROM painting_influence_sources pis
JOIN paintings p_work ON pis.painting_id = p_work.id
JOIN artists a ON p_work.artist_id = a.id
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE pis.source_artist_id = $1 AND a.id <> $1`;
const [predecessors, successors] = await Promise.all([
pool.query(
`SELECT * FROM (${predecessorSql}) u ORDER BY movement_name NULLS LAST, birth_year NULLS LAST, name`,
[artistId]
),
pool.query(
`SELECT * FROM (${successorSql}) u ORDER BY movement_name NULLS LAST, birth_year NULLS LAST, name`,
[artistId]
),
]);
res.json({
predecessors: groupByMovement(predecessors.rows),
successors: groupByMovement(successors.rows),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch artist navigation' });
}
});
// Update artist portrait checkup flags (checked / fixed)
app.patch('/api/artists/:id/checkup-flags', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { checked, fixed } = req.body ?? {};
if (checked === undefined && fixed === undefined) {
return res.status(400).json({ error: 'Provide checked and/or fixed boolean' });
}
if (checked !== undefined && typeof checked !== 'boolean') {
return res.status(400).json({ error: 'checked must be a boolean' });
}
if (fixed !== undefined && typeof fixed !== 'boolean') {
return res.status(400).json({ error: 'fixed must be a boolean' });
}
const current = await pool.query(
`SELECT checkup_checked, checkup_fixed FROM artists WHERE id = $1`,
[artistId]
);
if (current.rows.length === 0) {
return res.status(404).json({ error: 'Artist not found' });
}
const willBeFixed = fixed !== undefined ? fixed : !!current.rows[0].checkup_fixed;
let nextChecked = checked;
if (willBeFixed) {
nextChecked = true;
}
const sets = [];
const params = [];
if (fixed !== undefined) {
params.push(fixed);
sets.push(`checkup_fixed = $${params.length}`);
}
if (nextChecked !== undefined) {
params.push(nextChecked);
sets.push(`checkup_checked = $${params.length}`);
}
params.push(artistId);
const result = await pool.query(
`UPDATE artists SET ${sets.join(', ')}
WHERE id = $${params.length}
RETURNING checkup_checked AS checked, checkup_fixed AS fixed`,
params
);
res.json({
checked: !!result.rows[0].checked,
fixed: !!result.rows[0].fixed,
});
await logCuratorAction({
userId: req.curatorUser.id,
action: 'artist.checkup_flags',
resourceType: 'artist',
resourceId: artistId,
details: { checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed },
req,
});
} catch (err) {
console.error('Artist checkup flags error:', err.message);
res.status(500).json({ error: 'Failed to update checkup flags' });
}
});
// Developer debug: portrait image search for artist bio
app.get('/api/artists/:id/debug-portrait-search/more', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Artist not found' });
}
const { name } = result.rows[0];
const search = await searchArtistPortraitMany(name, limit);
res.json(search);
} catch (err) {
console.error('Debug portrait search (more) error:', err.message);
res.status(500).json({ error: 'Portrait search failed' });
}
});
app.get('/api/artists/:id/debug-portrait-search', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Artist not found' });
}
const { name } = result.rows[0];
const search = await searchArtistPortraitFirst(name);
res.json(search);
} catch (err) {
console.error('Debug portrait search error:', err.message);
res.status(500).json({ error: 'Portrait search failed' });
}
});
// Developer debug: replace artist portrait with a search result URL
app.post('/api/artists/:id/fix-portrait', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
return res.status(400).json({ error: 'Valid imageUrl required' });
}
const updated = await replaceArtistPortraitFromUrl(artistId, imageUrl, {
searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined,
source: typeof source === 'string' ? source : undefined,
pageUrl: typeof pageUrl === 'string' ? pageUrl : undefined,
thumbUrl: typeof thumbUrl === 'string' ? thumbUrl : undefined,
});
await pool.query(
`UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
[artistId]
);
res.json({ ...updated, fixed: true, checked: true });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'artist.fix_portrait',
resourceType: 'artist',
resourceId: artistId,
details: { imageUrl, source: typeof source === 'string' ? source : undefined },
req,
});
} catch (err) {
console.error('Fix portrait error:', err.message);
res.status(500).json({ error: friendlyImageFetchError(err) });
}
});
app.post('/api/artists/:id/clear-portrait', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const updated = await clearArtistPortrait(artistId);
await pool.query(
`UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
[artistId]
);
res.json({ ...updated, fixed: true, checked: true });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'artist.clear_portrait',
resourceType: 'artist',
resourceId: artistId,
req,
});
} catch (err) {
console.error('Clear portrait error:', err.message);
res.status(500).json({ error: err.message || 'Could not clear portrait' });
}
});
app.post('/api/artists/:id/upload-portrait', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { imageData, mimeType } = req.body ?? {};
if (!imageData || typeof imageData !== 'string') {
return res.status(400).json({ error: 'imageData required' });
}
const buffer = Buffer.from(imageData, 'base64');
if (!buffer.length) {
return res.status(400).json({ error: 'Empty image data' });
}
if (buffer.length > 15 * 1024 * 1024) {
return res.status(400).json({ error: 'Image too large (max 15 MB)' });
}
const updated = await replaceArtistPortraitFromBuffer(
artistId,
buffer,
typeof mimeType === 'string' ? mimeType : 'image/jpeg'
);
await pool.query(
`UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
[artistId]
);
res.json({ ...updated, fixed: true, checked: true });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'artist.upload_portrait',
resourceType: 'artist',
resourceId: artistId,
details: { mimeType: typeof mimeType === 'string' ? mimeType : 'image/jpeg', bytes: buffer.length },
req,
});
} catch (err) {
console.error('Upload portrait error:', err.message);
res.status(500).json({ error: err.message || 'Could not upload portrait' });
}
});
// Artist detail with periods and paintings
app.get('/api/artists/:id', async (req, res) => {
try {
const { id } = req.params;
const [artist, periods, paintings] = await Promise.all([
pool.query(
`SELECT a.*, m.name as movement_name, m.color as movement_color
FROM artists a
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE a.id = $1`,
[id]
),
pool.query(
`SELECT * FROM artist_periods WHERE artist_id = $1 ORDER BY sort_order, start_year`,
[id]
),
pool.query(
`SELECT p.*,
p.checkup_checked,
p.checkup_fixed,
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
FROM paintings p
WHERE p.artist_id = $1
ORDER BY p.sort_order, p.year`,
[id]
),
]);
if (artist.rows.length === 0) {
return res.status(404).json({ error: 'Artist not found' });
}
res.json({
artist: artist.rows[0],
periods: periods.rows,
paintings: paintings.rows,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch artist' });
}
});
// Painting image checkup (developer audit table) — must be before /api/paintings/:id
app.get('/api/paintings/checkup', requireCurator, async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path,
p.checkup_checked AS checked, p.checkup_fixed AS fixed,
a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
ORDER BY a.name, p.year NULLS LAST, p.title`
);
const paintings = rows.map((row) => {
const galleryFile = row.image_path || row.thumbnail_path || null;
const detailFile = row.image_path || row.thumbnail_path || null;
const detailOnDemand = !detailFile;
const galleryFileExists = galleryFile
? fs.existsSync(path.join(IMAGE_DIR, galleryFile))
: false;
const detailFileExists = detailFile
? fs.existsSync(path.join(IMAGE_DIR, detailFile))
: false;
return {
id: row.id,
title: row.title,
artist_name: row.artist_name,
year: row.year,
gallery_file: galleryFile,
gallery_preview: row.thumbnail_path || galleryFile,
detail_file: detailOnDemand ? `/api/paintings/${row.id}/image?size=full` : detailFile,
detail_preview: row.thumbnail_path || (detailOnDemand ? null : detailFile),
detail_on_demand: detailOnDemand,
gallery_file_exists: galleryFileExists,
detail_file_exists: detailOnDemand ? null : detailFileExists,
checked: !!row.checked,
fixed: !!row.fixed,
};
});
res.json({ paintings, total: paintings.length });
} catch (err) {
console.error('Checkup error:', err.message);
res.status(500).json({ error: 'Failed to load checkup data' });
}
});
// Update checkup workflow flags (checked / fixed)
app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { checked, fixed } = req.body ?? {};
if (checked === undefined && fixed === undefined) {
return res.status(400).json({ error: 'Provide checked and/or fixed boolean' });
}
if (checked !== undefined && typeof checked !== 'boolean') {
return res.status(400).json({ error: 'checked must be a boolean' });
}
if (fixed !== undefined && typeof fixed !== 'boolean') {
return res.status(400).json({ error: 'fixed must be a boolean' });
}
const current = await pool.query(
`SELECT checkup_checked, checkup_fixed FROM paintings WHERE id = $1`,
[paintingId]
);
if (current.rows.length === 0) {
return res.status(404).json({ error: 'Painting not found' });
}
const willBeFixed =
fixed !== undefined ? fixed : !!current.rows[0].checkup_fixed;
let nextChecked = checked;
if (willBeFixed) {
nextChecked = true;
}
const sets = [];
const params = [];
if (fixed !== undefined) {
params.push(fixed);
sets.push(`checkup_fixed = $${params.length}`);
}
if (nextChecked !== undefined) {
params.push(nextChecked);
sets.push(`checkup_checked = $${params.length}`);
}
params.push(paintingId);
const result = await pool.query(
`UPDATE paintings SET ${sets.join(', ')}
WHERE id = $${params.length}
RETURNING checkup_checked AS checked, checkup_fixed AS fixed`,
params
);
res.json({
checked: !!result.rows[0].checked,
fixed: !!result.rows[0].fixed,
});
await logCuratorAction({
userId: req.curatorUser.id,
action: 'painting.checkup_flags',
resourceType: 'painting',
resourceId: paintingId,
details: { checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed },
req,
});
} catch (err) {
console.error('Checkup flags error:', err.message);
res.status(500).json({ error: 'Failed to update checkup flags' });
}
});
// Painting detail with influences
app.get('/api/paintings/:id', async (req, res) => {
try {
const { id } = req.params;
const painting = await pool.query(
`SELECT p.*, a.name as artist_name, a.id as artist_id, a.portrait_path as artist_portrait,
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
FROM paintings p
JOIN artists a ON p.artist_id = a.id
WHERE p.id = $1`,
[id]
);
if (painting.rows.length === 0) {
return res.status(404).json({ error: 'Painting not found' });
}
const [influencedBy, influenced, annotations] = await Promise.all([
pool.query(INFLUENCED_BY_SQL, [id]),
pool.query(INFLUENCED_SQL, [id]),
pool.query(
`SELECT id, label, body, category, pos_x, pos_y, source_author, source, source_url, sort_order, confidence
FROM painting_annotations
WHERE painting_id = $1
ORDER BY sort_order, id`,
[id]
),
]);
res.json({
painting: painting.rows[0],
influencedBy: influencedBy.rows,
influenced: influenced.rows,
annotations: annotations.rows,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch painting' });
}
});
// Fast preload: link local image files only (no external downloads)
app.post('/api/artists/:id/preload-images', async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const result = await preloadArtistImagesLocal(artistId);
res.json(result);
} catch (err) {
console.error('Preload error:', err.message);
res.status(500).json({ error: 'Preload failed' });
}
});
// Developer debug: Google Images first result for image audit
app.get('/api/paintings/:id/debug-image-search/more', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20));
const result = await pool.query(
`SELECT p.title, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Painting not found' });
}
const { title, artist_name: artistName } = result.rows[0];
const search = await searchPaintingImagesMany(artistName, title, limit);
res.json(search);
} catch (err) {
console.error('Debug image search (more) error:', err.message);
res.status(500).json({ error: 'Image search failed' });
}
});
app.get('/api/paintings/:id/debug-image-search', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const result = await pool.query(
`SELECT p.title, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Painting not found' });
}
const { title, artist_name: artistName } = result.rows[0];
const search = await searchGoogleImagesFirst(artistName, title);
res.json(search);
} catch (err) {
console.error('Debug image search error:', err.message);
res.status(500).json({ error: 'Image search failed' });
}
});
// Developer debug: replace painting image with a search result URL
app.post('/api/paintings/:id/fix-image', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {};
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
return res.status(400).json({ error: 'Valid imageUrl required' });
}
const updated = await replacePaintingImageFromUrl(paintingId, imageUrl, {
searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined,
source: typeof source === 'string' ? source : undefined,
pageUrl: typeof pageUrl === 'string' ? pageUrl : undefined,
thumbUrl: typeof thumbUrl === 'string' ? thumbUrl : undefined,
});
await pool.query(
`UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
[paintingId]
);
res.json({ ...updated, fixed: true, checked: true });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'painting.fix_image',
resourceType: 'painting',
resourceId: paintingId,
details: { imageUrl, source: typeof source === 'string' ? source : undefined },
req,
});
} catch (err) {
console.error('Fix image error:', err.message);
res.status(500).json({ error: friendlyImageFetchError(err) });
}
});
app.delete('/api/paintings/:id', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
if (!Number.isFinite(paintingId)) {
return res.status(400).json({ error: 'Invalid painting id' });
}
const removed = await deletePainting(paintingId);
res.json(removed);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'painting.delete',
resourceType: 'painting',
resourceId: paintingId,
details: { title: removed.title, artistId: removed.artistId },
req,
});
} catch (err) {
console.error('Delete painting error:', err.message);
const status = err.message === 'Painting not found' ? 404 : 500;
res.status(status).json({ error: err.message || 'Could not remove painting' });
}
});
app.post('/api/paintings/:id/clear-image', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const updated = await clearPaintingImage(paintingId);
await pool.query(
`UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
[paintingId]
);
res.json({ ...updated, fixed: true, checked: true });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'painting.clear_image',
resourceType: 'painting',
resourceId: paintingId,
req,
});
} catch (err) {
console.error('Clear image error:', err.message);
res.status(500).json({ error: err.message || 'Could not clear image' });
}
});
app.post('/api/paintings/:id/upload-image', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { imageData, mimeType } = req.body ?? {};
if (!imageData || typeof imageData !== 'string') {
return res.status(400).json({ error: 'imageData required' });
}
const buffer = Buffer.from(imageData, 'base64');
if (!buffer.length) {
return res.status(400).json({ error: 'Empty image data' });
}
if (buffer.length > 15 * 1024 * 1024) {
return res.status(400).json({ error: 'Image too large (max 15 MB)' });
}
const updated = await replacePaintingImageFromBuffer(
paintingId,
buffer,
typeof mimeType === 'string' ? mimeType : 'image/jpeg'
);
await pool.query(
`UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`,
[paintingId]
);
res.json({ ...updated, fixed: true, checked: true });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'painting.upload_image',
resourceType: 'painting',
resourceId: paintingId,
details: { mimeType: typeof mimeType === 'string' ? mimeType : 'image/jpeg', bytes: buffer.length },
req,
});
} catch (err) {
console.error('Upload image error:', err.message);
res.status(500).json({ error: err.message || 'Could not upload image' });
}
});
// Proxy remote image for debug preview (avoids hotlink / CORS blocks)
app.get('/api/debug/image-proxy', requireCurator, async (req, res) => {
try {
const imageUrl = req.query.url;
const searchUrl = req.query.searchUrl;
const source = req.query.source;
if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) {
return res.status(400).json({ error: 'Valid url query required' });
}
const buffer = await fetchImageBuffer(imageUrl, {
searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined,
source: typeof source === 'string' ? source : undefined,
});
const ext = pickExt(imageUrl).toLowerCase();
const type =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', 'no-store');
res.send(buffer);
} catch (err) {
console.error('Image proxy error:', err.message);
res.status(502).json({ error: 'Could not load preview image' });
}
});
// On-demand painting image (resolves, caches, serves thumb or full)
app.get('/api/paintings/:id/image', async (req, res) => {
try {
const { id } = req.params;
const size = req.query.size === 'full' ? 'full' : 'thumb';
const relPath = await ensurePaintingImages(parseInt(id, 10), size);
if (!relPath) {
return res.status(404).json({ error: 'Image not found' });
}
const absPath = path.join(IMAGE_DIR, relPath);
if (!fs.existsSync(absPath)) {
return res.status(404).json({ error: 'Image file missing' });
}
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(absPath);
} catch (err) {
console.error('Image fetch error:', err.message);
res.status(500).json({ error: 'Image fetch failed' });
}
});
// Year range bounds
app.get('/api/bounds', async (req, res) => {
try {
const bounds = await fetchCatalogBounds();
res.json(bounds);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch bounds' });
}
});
const CLIENT_DIST = path.join(__dirname, '..', 'client', 'dist');
if (fs.existsSync(CLIENT_DIST)) {
app.use(express.static(CLIENT_DIST));
app.get(/^(?!\/api|\/images).*/, (_req, res) => {
res.sendFile(path.join(CLIENT_DIST, 'index.html'));
});
}
app.listen(PORT, HOST, () => {
console.log(`Gallery listening on http://${HOST}:${PORT}`);
if (PUBLIC_URL) console.log(`Public URL: ${PUBLIC_URL}`);
});