fix(gallery): a missing file must not take the backend down (#1128)

LocalFsStorage.get() returns an fs.createReadStream, which is lazy: it resolves
immediately and opens the file on a later tick, so an ENOENT arrives after the
await returned and outside the route's try/catch. An unhandled 'error' event is
a process-level throw Express cannot catch — the backend exits and every gallery
goes blank until the container restarts.

gallery.js had ten .pipe(res) calls and zero error handlers.

pipeStreamToResponse attaches the missing handler: a vanished source becomes a
404 (410 for a prepared zip), anything else a 500, and a source that dies
mid-response destroys the connection rather than rewriting a status already on
the wire. Headers staged for the file are cleared first — Express does not
overwrite an existing Content-Type, and a surviving Cache-Control would let a
transient 404 be cached as a broken tile for up to an hour. It also releases the
source when a client hangs up.

Applied to all eight streaming responses, not just the thumbnail route.

Stable twin of #1133, reduced: the tier-race half does not apply here because
ensureThumbnailAtWidth does not exist on this branch.
This commit is contained in:
Paul Nothaft
2026-08-22 21:36:43 +02:00
committed by GitHub
parent dc9e3cdc5e
commit da44f1947b
3 changed files with 250 additions and 8 deletions
+9 -8
View File
@@ -29,6 +29,7 @@ const { resolveGuest } = require('../middleware/guestAuth');
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { pipeStreamToResponse } = require('../utils/streamResponse');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
@@ -1044,7 +1045,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
res.setHeader('Content-Length', zipInfo.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const stream = await storage.get(zipInfo.key);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
// Log bulk download
db('access_logs').insert({
@@ -1505,7 +1506,7 @@ router.get('/:slug/photo/:photoId',
const file = useStorageBackend
? await storage.getRange(storageKey, start, end)
: fs.createReadStream(filePath, { start, end });
file.pipe(res);
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
} else {
res.writeHead(200, {
'Content-Length': fileSize,
@@ -1517,7 +1518,7 @@ router.get('/:slug/photo/:photoId',
const file = useStorageBackend
? await storage.get(storageKey)
: fs.createReadStream(filePath);
file.pipe(res);
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
}
return;
}
@@ -1551,7 +1552,7 @@ router.get('/:slug/photo/:photoId',
'X-Protection-Level': 'basic'
});
const wmStream = await storage.get(photo.watermark_path);
return wmStream.pipe(res);
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
}
} else {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
@@ -1600,7 +1601,7 @@ router.get('/:slug/photo/:photoId',
res.set('Content-Length', stat.size);
if (photo.mime_type) res.set('Content-Type', photo.mime_type);
const stream = await storage.get(storageKey);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
} else {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
res.sendFile(absolutePath);
@@ -1695,7 +1696,7 @@ router.get('/:slug/thumbnail/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(thumbnailPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve thumbnail');
@@ -1781,7 +1782,7 @@ router.get('/:slug/hero/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(heroPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving hero image:', {
@@ -1877,7 +1878,7 @@ router.get('/:slug/preview/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving preview image:', {
+81
View File
@@ -0,0 +1,81 @@
/**
* Pipe a file/storage stream to an Express response without betting the
* process on the source still being there (#1128).
*
* `fs.createReadStream` — what LocalFsStorage.get() returns — is LAZY. It
* resolves immediately and only opens the file on a later tick, so an ENOENT
* arrives AFTER the `await` returned and outside the route's try/catch. An
* EventEmitter that emits 'error' with no listener throws, and an uncaught
* throw from an I/O callback is not something Express can catch: Node exits.
*
* That is how one missing thumbnail tier took down every gallery on the
* install — the process died on the first grid load and only came back
* because Docker restarted it.
*
* The window is real and cannot be closed by a stat() beforehand: between the
* stat and the open, another request regenerating the same derivative can
* unlink it. So the handler is the fix, not the preflight.
*/
const logger = require('./logger');
/**
* @param {import('stream').Readable} stream source, already opened or lazy
* @param {import('express').Response} res
* @param {object} [options]
* @param {string} [options.context] what was being served, for the log line
* @param {number} [options.missingStatus=404] status when the source is gone
*/
function pipeStreamToResponse(stream, res, options = {}) {
const { context = 'file', missingStatus = 404 } = options;
stream.on('error', (err) => {
const gone = err && (err.code === 'ENOENT' || err.code === 'EISDIR');
// Once bytes are on the wire the status line is spent — there is no way to
// turn this into a 404. Destroy the response so the client sees a broken
// connection rather than a silently truncated image it would cache.
if (res.headersSent) {
logger.warn(`Stream failed mid-response for ${context}: ${err.message}`);
res.destroy(err);
return;
}
// Every header staged for the FILE now describes a body that will never
// be sent. They are cleared rather than left to Express, which does not
// overwrite a Content-Type that is already set — so without this the JSON
// error goes out as `image/jpeg`, or as an `application/zip` attachment
// that saves to disk as a corrupt download.
//
// Cache-Control matters most. The image routes stage `max-age=1800` (the
// hero route 3600), so a 404 from the regeneration race — the transient
// case this whole helper exists for — would be cached as a broken tile for
// up to an hour after the tier finished generating.
res.removeHeader('Content-Length');
res.removeHeader('ETag');
res.removeHeader('Content-Type');
res.removeHeader('Content-Disposition');
res.setHeader('Cache-Control', 'no-store');
if (gone) {
// Expected under the regeneration race — the tier existed at stat time
// and was replaced before the open. One broken tile, not an outage.
logger.warn(`Source vanished while serving ${context}: ${err.message}`);
res.status(missingStatus).json({ error: 'File not found' });
return;
}
logger.error(`Failed to stream ${context}`, { error: err.message, code: err.code });
res.status(500).json({ error: 'Failed to serve file' });
});
// A client that navigates away mid-download leaves the source handle open
// otherwise; on a gallery grid that is one leaked fd per abandoned tile.
res.on('close', () => {
if (!res.writableEnded) stream.destroy();
});
stream.pipe(res);
}
module.exports = { pipeStreamToResponse };