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
+32
View File
@@ -0,0 +1,32 @@
const pool = require('./db');
function clientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return forwarded.split(',')[0].trim();
}
return req.ip || null;
}
async function logCuratorAction({ userId, action, resourceType, resourceId, details, req }) {
if (!userId) return;
try {
await pool.query(
`INSERT INTO curator_audit_log (user_id, action, resource_type, resource_id, details, ip_address)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
userId,
action,
resourceType,
resourceId,
details ? JSON.stringify(details) : null,
req ? clientIp(req) : null,
]
);
} catch (err) {
console.error('Audit log error:', err.message);
}
}
module.exports = { logCuratorAction };
+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;
+27
View File
@@ -0,0 +1,27 @@
const pool = require('../db');
async function requireCurator(req, res, next) {
const userId = req.session?.userId;
if (!userId) {
return res.status(401).json({ error: 'Curator login required' });
}
try {
const { rows } = await pool.query(
`SELECT id, username FROM users WHERE id = $1`,
[userId]
);
if (rows.length === 0) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Curator login required' });
}
req.curatorUser = rows[0];
next();
} catch (err) {
console.error('Auth middleware error:', err.message);
res.status(500).json({ error: 'Authentication failed' });
}
}
module.exports = { requireCurator };
+38
View File
@@ -0,0 +1,38 @@
const session = require('express-session');
const pgSession = require('connect-pg-simple')(session);
const pool = require('../db');
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
function createSessionMiddleware() {
const secret = process.env.SESSION_SECRET;
if (!secret) {
console.warn(
'SESSION_SECRET is not set — using insecure default (set SESSION_SECRET in production)'
);
}
const secureCookie =
process.env.SESSION_COOKIE_SECURE === '1' ||
process.env.SESSION_COOKIE_SECURE === 'true';
return session({
store: new pgSession({
pool,
tableName: 'session',
createTableIfMissing: false,
}),
name: 'gallery.sid',
secret: secret || 'gallery-dev-insecure-session-secret',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: secureCookie,
sameSite: 'lax',
maxAge: SEVEN_DAYS_MS,
},
});
}
module.exports = { createSessionMiddleware };
+27
View File
@@ -9,8 +9,32 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-influence-sources.sql',
'migrate-painting-annotations.sql',
'migrate-artist-palette.sql',
'migrate-auth.sql',
];
async function bootstrapCurator() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM users');
if (rows[0].n > 0) {
console.log(' curator bootstrap: users table already populated');
return;
}
const username = (process.env.CURATOR_USERNAME || 'curator').trim();
const password = process.env.CURATOR_PASSWORD;
if (!password) {
console.warn(' curator bootstrap skipped: set CURATOR_PASSWORD to create the first curator account');
return;
}
const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash(password, 10);
await pool.query(
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
[username, passwordHash]
);
console.log(` bootstrap curator account: ${username}`);
}
async function applySqlFile(label, filePath) {
const sql = fs.readFileSync(filePath, 'utf8');
await pool.query(sql);
@@ -34,6 +58,9 @@ async function migrate() {
await applySqlFile(file, filePath);
}
console.log('Bootstrapping curator account (if needed) …');
await bootstrapCurator();
console.log('Database migration complete.');
}
+80
View File
@@ -0,0 +1,80 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const pool = require('../db');
const router = express.Router();
router.get('/me', async (req, res) => {
const userId = req.session?.userId;
if (!userId) {
return res.json({ role: 'user' });
}
try {
const { rows } = await pool.query(
`SELECT id, username FROM users WHERE id = $1`,
[userId]
);
if (rows.length === 0) {
req.session.destroy(() => {});
return res.json({ role: 'user' });
}
res.json({
role: 'curator',
username: rows[0].username,
});
} catch (err) {
console.error('Auth me error:', err.message);
res.status(500).json({ error: 'Failed to read session' });
}
});
router.post('/login', async (req, res) => {
const { username, password } = req.body ?? {};
if (!username || typeof username !== 'string' || !password || typeof password !== 'string') {
return res.status(400).json({ error: 'Username and password required' });
}
try {
const { rows } = await pool.query(
`SELECT id, username, password_hash FROM users WHERE username = $1`,
[username.trim()]
);
if (rows.length === 0) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const user = rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Invalid username or password' });
}
await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]);
req.session.userId = user.id;
req.session.username = user.username;
res.json({
role: 'curator',
username: user.username,
});
} catch (err) {
console.error('Auth login error:', err.message);
res.status(500).json({ error: 'Login failed' });
}
});
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Auth logout error:', err.message);
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('gallery.sid');
res.json({ ok: true });
});
});
module.exports = router;