Fix debug upload persistence and UX; exclude prod audit log from DB restore

Uploads and fixes now bust browser cache via file-mtime keys in API
responses. Debug upload shows a centered loading overlay and blocks search
while uploading. Prod DB restore skips curator_audit_log.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-09 18:30:19 +03:00
co-authored by Cursor
parent 62096e8210
commit f78c14f307
18 changed files with 506 additions and 154 deletions
+68 -7
View File
@@ -13,6 +13,59 @@ const FETCH_TIMEOUT_MS = 15000;
const PORTRAIT_THUMB_WIDTH = 256;
const inflight = new Map();
function imageFileCacheKey(relPath) {
if (!relPath || typeof relPath !== 'string') return null;
const abs = path.join(IMAGE_DIR, relPath.replace(/^\//, ''));
try {
if (!fs.existsSync(abs)) return null;
return Math.floor(fs.statSync(abs).mtimeMs);
} catch {
return null;
}
}
function enrichPaintingRow(row) {
if (!row || typeof row !== 'object') return row;
return {
...row,
image_cache_key: imageFileCacheKey(row.image_path),
thumbnail_cache_key: imageFileCacheKey(row.thumbnail_path),
};
}
function enrichArtistRow(row) {
if (!row || typeof row !== 'object') return row;
return {
...row,
portrait_cache_key: imageFileCacheKey(row.portrait_path),
portrait_thumb_cache_key: imageFileCacheKey(row.portrait_thumb_path),
};
}
function paintingImagePayload(paths, extra = {}) {
const imagePath = paths.imagePath ?? paths.image_path ?? null;
const thumbnailPath = paths.thumbnailPath ?? paths.thumbnail_path ?? null;
return {
imagePath,
thumbnailPath,
image_cache_key: imageFileCacheKey(imagePath),
thumbnail_cache_key: imageFileCacheKey(thumbnailPath),
...extra,
};
}
function artistPortraitPayload(paths, extra = {}) {
const portraitPath = paths.portraitPath ?? paths.portrait_path ?? null;
const portraitThumbPath = paths.portraitThumbPath ?? paths.portrait_thumb_path ?? null;
return {
portraitPath,
portraitThumbPath,
portrait_cache_key: imageFileCacheKey(portraitPath),
portrait_thumb_cache_key: imageFileCacheKey(portraitThumbPath),
...extra,
};
}
function safePaintingBase(artistName, title) {
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
}
@@ -269,7 +322,7 @@ async function clearPaintingImage(paintingId) {
[paintingId]
);
return { imagePath: null, thumbnailPath: null };
return paintingImagePayload({ imagePath: null, thumbnailPath: null });
}
async function clearArtistPortrait(artistId) {
@@ -290,7 +343,7 @@ async function clearArtistPortrait(artistId) {
[artistId]
);
return { portraitPath: null, portraitThumbPath: null };
return artistPortraitPayload({ portraitPath: null, portraitThumbPath: null });
}
async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
@@ -344,7 +397,7 @@ async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
[imagePath, thumbnailPath, paintingId]
);
return { imagePath, thumbnailPath };
return paintingImagePayload({ imagePath, thumbnailPath });
}
async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
@@ -387,12 +440,14 @@ async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
fs.writeFileSync(fullDest, buffer);
const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
const portraitThumbPath = (await writePortraitThumb(fullDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
return artistPortraitPayload(updated);
}
const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
return artistPortraitPayload(updated);
}
async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
@@ -436,7 +491,7 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
[imagePath, thumbnailPath, paintingId]
);
return { imagePath, thumbnailPath };
return paintingImagePayload({ imagePath, thumbnailPath });
}
async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
@@ -480,7 +535,8 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
const sourceForThumb = fs.existsSync(jpgDest) ? jpgDest : path.join(IMAGE_DIR, portraitPath);
const portraitThumbPath = (await writePortraitThumb(sourceForThumb, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
return artistPortraitPayload(updated);
}
module.exports = {
@@ -493,5 +549,10 @@ module.exports = {
clearArtistPortrait,
replacePaintingImageFromBuffer,
replaceArtistPortraitFromBuffer,
enrichPaintingRow,
enrichArtistRow,
paintingImagePayload,
artistPortraitPayload,
imageFileCacheKey,
IMAGE_DIR,
};
+9 -9
View File
@@ -11,7 +11,7 @@ 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 { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const { getVersionInfo } = require('./version-info');
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
@@ -32,10 +32,10 @@ app.use('/api/auth', authRoutes);
app.use(
'/images',
express.static(IMAGE_DIR, {
maxAge: '365d',
immutable: true,
etag: true,
lastModified: true,
setHeaders(res) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
},
})
);
@@ -250,7 +250,7 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
res.json({
movement: movement.rows[0],
paintings: paintings.rows,
paintings: paintings.rows.map(enrichPaintingRow),
});
} catch (err) {
console.error(err);
@@ -628,9 +628,9 @@ app.get('/api/artists/:id', async (req, res) => {
}
res.json({
artist: artist.rows[0],
artist: enrichArtistRow(artist.rows[0]),
periods: periods.rows,
paintings: paintings.rows,
paintings: paintings.rows.map(enrichPaintingRow),
});
} catch (err) {
console.error(err);
@@ -786,9 +786,9 @@ app.get('/api/paintings/:id', async (req, res) => {
]);
res.json({
painting: painting.rows[0],
painting: enrichPaintingRow(painting.rows[0]),
influencedBy: influencedBy.rows,
influenced: influenced.rows,
influenced: influenced.rows.map(enrichPaintingRow),
annotations: annotations.rows,
});
} catch (err) {