Skip to content

Commit ba01e84

Browse files
fix(uploads): add return on null-stream 404 + guard sharp transform errors
Address two CodeRabbit findings: 1. Add return before responses.error(res, 404) in both get() and getSharp() so execution does not fall through to stream.on() when getStream returns falsy — preventing a TypeError and ERR_HTTP_HEADERS_SENT crash on the null-stream path. 2. Attach the headersSent-gated error handler to the sharp Transform (not just the GridFS source stream). pipe() does not forward error events between streams, so sharp-pipeline failures were unhandled and could crash the worker process. Refactored to buildTransform() helper that creates the transform, attaches the error handler, and returns it. Update comment to accurately describe when headersSent flips to true (first chunk written, not res.set()). Add test: sharp transform error after headers sent → res.destroy + logger. Full unit suite: 1623/1623 pass.
1 parent a50c4f3 commit ba01e84

2 files changed

Lines changed: 61 additions & 28 deletions

File tree

modules/uploads/controllers/uploads.controller.js

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ const normalizeMime = (raw) => String(raw).toLowerCase().split(';')[0].trim();
3939
const get = async (req, res) => {
4040
try {
4141
const stream = await UploadsService.getStream({ _id: req.upload._id });
42-
if (!stream) responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
42+
if (!stream) return responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
4343
stream.on('error', (err) => {
44-
// Guard against ERR_HTTP_HEADERS_SENT when GridFS fails mid-transfer:
45-
// `res.set('Content-Type', ...)` below flushes response headers to the
46-
// client before the stream starts. If the GridFS read then errors, a
44+
// Guard against ERR_HTTP_HEADERS_SENT when GridFS fails mid-transfer.
45+
// Response headers are written when the first chunk is piped to res
46+
// (`stream.pipe(res)` below). If GridFS errors after that point, a
4747
// second status+body write would throw ERR_HTTP_HEADERS_SENT and crash
4848
// the worker process. Pre-header errors still reach the client as 422;
4949
// post-header errors destroy the socket so Express surfaces them via
@@ -76,37 +76,45 @@ const get = async (req, res) => {
7676
const getSharp = async (req, res) => {
7777
try {
7878
const stream = await UploadsService.getStream({ _id: req.upload._id });
79-
if (!stream) responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
80-
stream.on('error', (err) => {
81-
// Same headersSent guard as `get` above. A sharp pipeline error after
82-
// `res.set('Content-Type', ...)` has flushed the response head cannot
83-
// write a new status or body — attempting to do so throws
84-
// ERR_HTTP_HEADERS_SENT. Destroy the socket instead so Express surfaces
85-
// the error via its default handler without double-sending.
79+
if (!stream) return responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
80+
// headersSent guard for the GridFS source stream.
81+
// pipe() does not forward 'error' events between streams; each stream in the
82+
// chain needs its own listener. Source and Transform errors are handled below.
83+
// Pre-header errors reach the client as 422; post-header errors destroy the
84+
// socket so Express surfaces them via its default handler instead of
85+
// attempting a second write that would throw ERR_HTTP_HEADERS_SENT.
86+
const onStreamError = (label) => (err) => {
8687
if (!res.headersSent) {
8788
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
8889
} else {
89-
logger.error('uploads.getSharp - stream error after headers sent', err);
90+
logger.error(`uploads.getSharp - ${label} error after headers sent`, err);
9091
res.destroy(err);
9192
}
92-
});
93+
};
94+
stream.on('error', onStreamError('source stream'));
9395
const raw = req.upload.contentType || req.upload.metadata?.contentType || 'application/octet-stream';
9496
const norm = normalizeMime(raw);
9597
const contentType = SAFE_IMAGE_MIME.has(norm) ? norm : 'image/jpeg';
9698
res.set('Content-Type', contentType);
97-
switch (req.sharpOption) {
98-
case 'blur':
99-
stream.pipe(sharp().resize(req.sharpSize).blur(config.uploads.sharp.blur)).pipe(res);
100-
break;
101-
case 'bw':
102-
stream.pipe(sharp().resize(req.sharpSize).grayscale()).pipe(res);
103-
break;
104-
case 'blur&bw':
105-
stream.pipe(sharp().resize(req.sharpSize).grayscale().blur(config.uploads.sharp.blur)).pipe(res);
106-
break;
107-
default:
108-
stream.pipe(sharp().resize(req.sharpSize)).pipe(res);
109-
}
99+
const buildTransform = () => {
100+
let transform;
101+
switch (req.sharpOption) {
102+
case 'blur':
103+
transform = sharp().resize(req.sharpSize).blur(config.uploads.sharp.blur);
104+
break;
105+
case 'bw':
106+
transform = sharp().resize(req.sharpSize).grayscale();
107+
break;
108+
case 'blur&bw':
109+
transform = sharp().resize(req.sharpSize).grayscale().blur(config.uploads.sharp.blur);
110+
break;
111+
default:
112+
transform = sharp().resize(req.sharpSize);
113+
}
114+
transform.on('error', onStreamError('sharp transform'));
115+
return transform;
116+
};
117+
stream.pipe(buildTransform()).pipe(res);
110118
} catch (err) {
111119
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
112120
}

modules/uploads/tests/uploads.controller.contenttype.unit.tests.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
3636
};
3737

3838
let mockLogger;
39+
// Captures the 'error' listener registered on the sharp Transform so tests
40+
// can fire it manually to simulate a sharp-pipeline mid-stream failure.
41+
let sharpListeners;
3942

4043
beforeEach(async () => {
4144
jest.resetModules();
@@ -47,6 +50,7 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
4750
};
4851

4952
mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn() };
53+
sharpListeners = {};
5054

5155
jest.unstable_mockModule('../services/uploads.service.js', () => ({
5256
default: mockUploadsService,
@@ -60,12 +64,16 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
6064
// sharp is a real dependency; mock it to avoid needing native bindings in unit context.
6165
// pipe must return `dest` (matching the real Stream.pipe contract) so that chained
6266
// calls stream.pipe(transform).pipe(res) work without throwing.
67+
// on() captures listeners so tests can fire transform error events.
6368
jest.unstable_mockModule('sharp', () => ({
6469
default: jest.fn(() => ({
6570
resize: jest.fn().mockReturnThis(),
6671
blur: jest.fn().mockReturnThis(),
6772
grayscale: jest.fn().mockReturnThis(),
6873
pipe: jest.fn((dest) => dest),
74+
on: jest.fn((event, handler) => {
75+
sharpListeners[event] = handler;
76+
}),
6977
})),
7078
}));
7179

@@ -492,20 +500,37 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
492500
expect(res.destroy).not.toHaveBeenCalled();
493501
});
494502

495-
test('calls res.destroy(err) and logger.error when stream errors after headers are sent', async () => {
503+
test('calls res.destroy(err) and logger.error when source stream errors after headers are sent', async () => {
496504
const { listeners } = installStream();
497505
const { default: responses } = await import('../../../lib/helpers/responses.js');
498506
const res = buildRes();
499507

500508
await UploadsController.getSharp(req, res);
501509

502510
res.headersSent = true;
503-
const err = new Error('sharp pipeline mid-stream abort');
511+
const err = new Error('gridfs source mid-stream abort');
504512
listeners.error(err);
505513

506514
expect(responses.error).not.toHaveBeenCalled();
507515
expect(res.destroy).toHaveBeenCalledWith(err);
508516
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('uploads.getSharp'), err);
509517
});
518+
519+
test('calls res.destroy(err) and logger.error when sharp transform errors after headers are sent', async () => {
520+
installStream();
521+
const { default: responses } = await import('../../../lib/helpers/responses.js');
522+
const res = buildRes();
523+
524+
await UploadsController.getSharp(req, res);
525+
526+
// Simulate a sharp transform error mid-stream (after headers are flushed).
527+
res.headersSent = true;
528+
const err = new Error('sharp transform mid-stream abort');
529+
sharpListeners.error(err);
530+
531+
expect(responses.error).not.toHaveBeenCalled();
532+
expect(res.destroy).toHaveBeenCalledWith(err);
533+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('uploads.getSharp'), err);
534+
});
510535
});
511536
});

0 commit comments

Comments
 (0)