Files
Art-gallery/server/index.js
T
Danila KhodjaefandCursor 0a5918c4dd Add movement artist filter modal and fix large-hall texture blanks
Clicking a movement opens ArtistFilterModal to choose artists before the gallery. Large halls no longer permanently blank frames when texture loads exceed the overlay deadline; fall back thumb to full to on-demand API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:05:54 +03:00

1235 lines
42 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 { requirePermission } = require('./middleware/auth');
const { logCuratorAction } = require('./audit-log');
const authRoutes = require('./routes/auth');
const usersRoutes = require('./routes/users');
const { ensurePaintingImages, preloadArtistImagesLocal, preloadMovementImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const { getVersionInfo } = require('./version-info');
const { searchCatalog } = require('./search-service');
const {
resolveLocale,
translationStatuses,
localizeEras,
localizeMovements,
localizeArtists,
localizePeriods,
localizePaintings,
localizeAnnotations,
localizeInfluenceSources,
} = require('./translation-service');
const translationRoutes = require('./routes/translations');
const influenceRoutes = require('./routes/influences');
const tourRoutes = require('./routes/tours');
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('/api/users', usersRoutes);
app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes);
app.use('/api/tours', tourRoutes);
app.use(
'/images',
express.static(IMAGE_DIR, {
etag: true,
lastModified: true,
setHeaders(res) {
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
},
})
);
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,
COALESCE(ic.link_count, 0)::int AS influence_link_count
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
LEFT JOIN (
SELECT a2.movement_id, COUNT(pis.id)::int AS link_count
FROM painting_influence_sources pis
JOIN paintings p ON p.id = pis.painting_id
JOIN artists a2 ON a2.id = p.artist_id
GROUP BY a2.movement_id
) ic ON ic.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,
(SELECT COUNT(*)::int FROM painting_influence_sources) AS influence_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;
}
function localeContext(req) {
return {
locale: resolveLocale(req),
statuses: translationStatuses(req),
};
}
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.id AS influence_source_id,
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.id AS influence_source_id,
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/search', async (req, res) => {
try {
const q = typeof req.query.q === 'string' ? req.query.q : '';
const limit = parseInt(req.query.limit, 10);
const types = typeof req.query.types === 'string' ? req.query.types : undefined;
const { locale } = localeContext(req);
const result = await searchCatalog(q, {
limit: Number.isFinite(limit) ? limit : 20,
types,
locale,
});
res.setHeader('Cache-Control', 'no-store');
res.json(result);
} catch (err) {
console.error('Search error:', err.message);
res.status(500).json({ error: 'Search failed' });
}
});
app.get('/api/timeline', async (req, res) => {
try {
const { start, end } = req.query;
const startYear = parseInt(start) || -3000;
const endYear = parseInt(end) || 2100;
const { locale, statuses } = localeContext(req);
const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear);
const localizedEras = await localizeEras(eras, locale, statuses);
const localizedMovements = await localizeMovements(movements, locale, statuses);
res.json({ locale, eras: localizedEras, movements: localizedMovements });
} 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 { locale, statuses } = localeContext(req);
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),
]);
const localizedEras = await localizeEras(eras, locale, statuses);
const localizedMovements = await localizeMovements(movements, locale, statuses);
const localizedArtists = await localizeArtists(artists, locale, statuses);
res.json({
locale,
bounds,
eras: localizedEras,
movements: localizedMovements,
artists: localizedArtists,
});
} 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,
a.id AS artist_id,
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]
);
const { locale, statuses } = localeContext(req);
const localizedMovement = (await localizeMovements([movement.rows[0]], locale, statuses))[0];
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
res.json({
locale,
movement: localizedMovement,
paintings: localizedPaintings.map(enrichPaintingRow),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch movement gallery' });
}
});
// Artists for a movement with painting counts (for gallery entry filter modal)
app.get('/api/movements/:id/artists-summary', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`SELECT a.id, a.name, a.birth_year, a.death_year, a.portrait_path, a.portrait_thumb_path,
COUNT(p.id)::int AS painting_count
FROM artists a
LEFT JOIN paintings p ON p.artist_id = a.id
WHERE a.movement_id = $1
GROUP BY a.id
ORDER BY a.birth_year NULLS LAST, a.name`,
[id]
);
const { locale, statuses } = localeContext(req);
const localized = await localizeArtists(result.rows, locale, statuses);
res.json(localized.map(enrichArtistRow));
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch movement artists summary' });
}
});
// 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, movement_id
FROM artists WHERE movement_id = $1
ORDER BY birth_year`,
[id]
);
const { locale, statuses } = localeContext(req);
const localized = await localizeArtists(result.rows, locale, statuses);
res.json(localized);
} 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);
const { locale, statuses } = localeContext(req);
const localized = await localizeArtists(result.rows, locale, statuses);
res.json(localized);
} 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]
),
]);
const { locale, statuses } = localeContext(req);
const locPred = await localizeArtists(predecessors.rows, locale, statuses);
const locSucc = await localizeArtists(successors.rows, locale, statuses);
res.json({
locale,
predecessors: groupByMovement(locPred),
successors: groupByMovement(locSucc),
});
} 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', requirePermission('checkup'), 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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' });
}
const { locale, statuses } = localeContext(req);
const localizedArtist = (await localizeArtists([artist.rows[0]], locale, statuses))[0];
const localizedPeriods = await localizePeriods(periods.rows, locale, statuses);
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
res.json({
locale,
artist: enrichArtistRow(localizedArtist),
periods: localizedPeriods,
paintings: localizedPaintings.map(enrichPaintingRow),
});
} 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', requirePermission('checkup'), 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', requirePermission('checkup'), 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' });
}
});
// Update public curator notes on a painting
app.patch('/api/paintings/:id/curator-notes', requirePermission('curator_notes'), async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
if (!Number.isFinite(paintingId)) {
return res.status(400).json({ error: 'Invalid painting id' });
}
if (typeof req.body?.curatorNotes !== 'string') {
return res.status(400).json({ error: 'curatorNotes must be a string' });
}
const curatorNotes = req.body.curatorNotes.trim();
const result = await pool.query(
`UPDATE paintings SET curator_notes = $2
WHERE id = $1
RETURNING curator_notes`,
[paintingId, curatorNotes]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Painting not found' });
}
res.json({ curatorNotes: result.rows[0].curator_notes ?? '' });
await logCuratorAction({
userId: req.curatorUser.id,
action: 'painting.update_curator_notes',
resourceType: 'painting',
resourceId: paintingId,
details: { length: curatorNotes.length },
req,
});
} catch (err) {
console.error('Curator notes update error:', err.message);
res.status(500).json({ error: 'Failed to update curator notes' });
}
});
// 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]
),
]);
const { locale, statuses } = localeContext(req);
const localizedPainting = (await localizePaintings([painting.rows[0]], locale, statuses))[0];
const localizedInfluencedBy = await localizeInfluenceSources(influencedBy.rows, locale, statuses);
const localizedInfluenced = await localizeInfluenceSources(
influenced.rows.map(enrichPaintingRow),
locale,
statuses,
);
const localizedAnnotations = await localizeAnnotations(annotations.rows, locale, statuses);
res.json({
locale,
painting: enrichPaintingRow(localizedPainting),
influencedBy: localizedInfluencedBy,
influenced: localizedInfluenced,
annotations: localizedAnnotations,
});
} 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' });
}
});
app.post('/api/movements/:id/preload-images', async (req, res) => {
try {
const movementId = parseInt(req.params.id, 10);
const result = await preloadMovementImagesLocal(movementId);
res.json(result);
} catch (err) {
console.error('Movement 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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', requirePermission('images'), 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' });
}
});
app.get('/api/version', async (_req, res) => {
res.json(getVersionInfo());
});
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}`);
});