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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user