Add curator authentication with audit logging and fix empty 3D gallery sessions.

Introduce session-based curator login, gate debug/checkup routes, log mutations to curator_audit_log, and keep guest hall preload public. Fix gallery view mounting so WebGL halls render reliably after navigation.
This commit is contained in:
Danila Khodjaef
2026-07-06 00:17:01 +03:00
parent aa31a2aa6e
commit 9da065acbe
27 changed files with 1252 additions and 112 deletions
+101 -16
View File
@@ -5,6 +5,10 @@ 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');
@@ -17,8 +21,10 @@ if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
app.use(cors());
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes);
app.use('/images', express.static(IMAGE_DIR));
const INFLUENCE_LINKS_EXISTS = `
@@ -297,7 +303,7 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
});
// Update artist portrait checkup flags (checked / fixed)
app.patch('/api/artists/:id/checkup-flags', async (req, res) => {
app.patch('/api/artists/:id/checkup-flags', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { checked, fixed } = req.body ?? {};
@@ -349,6 +355,15 @@ app.patch('/api/artists/:id/checkup-flags', async (req, res) => {
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' });
@@ -356,7 +371,7 @@ app.patch('/api/artists/:id/checkup-flags', async (req, res) => {
});
// Developer debug: portrait image search for artist bio
app.get('/api/artists/:id/debug-portrait-search/more', async (req, res) => {
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));
@@ -374,7 +389,7 @@ app.get('/api/artists/:id/debug-portrait-search/more', async (req, res) => {
}
});
app.get('/api/artists/:id/debug-portrait-search', async (req, res) => {
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]);
@@ -392,7 +407,7 @@ app.get('/api/artists/:id/debug-portrait-search', async (req, res) => {
});
// Developer debug: replace artist portrait with a search result URL
app.post('/api/artists/:id/fix-portrait', async (req, res) => {
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 ?? {};
@@ -411,13 +426,22 @@ app.post('/api/artists/:id/fix-portrait', async (req, res) => {
[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', async (req, res) => {
app.post('/api/artists/:id/clear-portrait', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const updated = await clearArtistPortrait(artistId);
@@ -426,13 +450,21 @@ app.post('/api/artists/:id/clear-portrait', async (req, res) => {
[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', async (req, res) => {
app.post('/api/artists/:id/upload-portrait', requireCurator, async (req, res) => {
try {
const artistId = parseInt(req.params.id, 10);
const { imageData, mimeType } = req.body ?? {};
@@ -457,6 +489,15 @@ app.post('/api/artists/:id/upload-portrait', async (req, res) => {
[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' });
@@ -507,7 +548,7 @@ app.get('/api/artists/:id', async (req, res) => {
});
// Painting image checkup (developer audit table) — must be before /api/paintings/:id
app.get('/api/paintings/checkup', async (_req, res) => {
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,
@@ -555,7 +596,7 @@ app.get('/api/paintings/checkup', async (_req, res) => {
});
// Update checkup workflow flags (checked / fixed)
app.patch('/api/paintings/:id/checkup-flags', async (req, res) => {
app.patch('/api/paintings/:id/checkup-flags', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { checked, fixed } = req.body ?? {};
@@ -608,6 +649,15 @@ app.patch('/api/paintings/:id/checkup-flags', async (req, res) => {
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' });
@@ -669,7 +719,7 @@ app.post('/api/artists/:id/preload-images', async (req, res) => {
});
// Developer debug: Google Images first result for image audit
app.get('/api/paintings/:id/debug-image-search/more', async (req, res) => {
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));
@@ -693,7 +743,7 @@ app.get('/api/paintings/:id/debug-image-search/more', async (req, res) => {
}
});
app.get('/api/paintings/:id/debug-image-search', async (req, res) => {
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(
@@ -717,7 +767,7 @@ app.get('/api/paintings/:id/debug-image-search', async (req, res) => {
});
// Developer debug: replace painting image with a search result URL
app.post('/api/paintings/:id/fix-image', async (req, res) => {
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 ?? {};
@@ -736,13 +786,22 @@ app.post('/api/paintings/:id/fix-image', async (req, res) => {
[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', async (req, res) => {
app.delete('/api/paintings/:id', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
if (!Number.isFinite(paintingId)) {
@@ -750,6 +809,15 @@ app.delete('/api/paintings/:id', async (req, res) => {
}
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;
@@ -757,7 +825,7 @@ app.delete('/api/paintings/:id', async (req, res) => {
}
});
app.post('/api/paintings/:id/clear-image', async (req, res) => {
app.post('/api/paintings/:id/clear-image', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const updated = await clearPaintingImage(paintingId);
@@ -766,13 +834,21 @@ app.post('/api/paintings/:id/clear-image', async (req, res) => {
[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', async (req, res) => {
app.post('/api/paintings/:id/upload-image', requireCurator, async (req, res) => {
try {
const paintingId = parseInt(req.params.id, 10);
const { imageData, mimeType } = req.body ?? {};
@@ -797,6 +873,15 @@ app.post('/api/paintings/:id/upload-image', async (req, res) => {
[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' });
@@ -804,7 +889,7 @@ app.post('/api/paintings/:id/upload-image', async (req, res) => {
});
// Proxy remote image for debug preview (avoids hotlink / CORS blocks)
app.get('/api/debug/image-proxy', async (req, res) => {
app.get('/api/debug/image-proxy', requireCurator, async (req, res) => {
try {
const imageUrl = req.query.url;
const searchUrl = req.query.searchUrl;