fix(upload): enforce the chunked-upload cap on bytes received, not declared
The init route checked the client-declared fileSize against general_max_file_size_mb, but nothing checked what then came through the chunk route: a client could declare `fileSize: 1` and stream any amount, and completeUpload only logged the size mismatch before handing the merged file on. The cap the earlier commit added at init was therefore a gate with no fence. The service now carries the cap from init and enforces it on the running byte total per chunk (aborting the upload once crossed, since the chunks on disk are already over the limit), rejects chunk indices outside the announced range, and re-checks the merged file as a backstop. Both routes answer 413/400 for these instead of a blanket 500.
This commit is contained in:
@@ -1623,7 +1623,10 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId: parseInt(eventId),
|
||||
totalChunks
|
||||
totalChunks,
|
||||
// The declared fileSize check above is client-controlled; the service
|
||||
// enforces this cap on the bytes it actually receives and merges.
|
||||
maxFileSizeBytes: maxSize
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
@@ -1648,6 +1651,9 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 413 || error.statusCode === 400) {
|
||||
return res.status(error.statusCode).json({ error: error.message });
|
||||
}
|
||||
logger.error('Error uploading chunk:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
|
||||
}
|
||||
@@ -1690,6 +1696,9 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
photos: uploadedPhotos
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.statusCode === 413) {
|
||||
return res.status(413).json({ error: error.message });
|
||||
}
|
||||
logger.error('Error completing chunked upload:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to complete upload' });
|
||||
}
|
||||
|
||||
@@ -16,6 +16,27 @@ const CHUNK_SIZE = 10 * 1024 * 1024;
|
||||
// Upload expiration: 24 hours
|
||||
const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function totalReceivedBytes(uploadMeta) {
|
||||
let total = 0;
|
||||
for (const size of uploadMeta.chunkSizes.values()) total += size;
|
||||
return total;
|
||||
}
|
||||
|
||||
// Tagged errors so the routes can answer 413/400 instead of a blanket 500.
|
||||
function fileTooLargeError(maxFileSizeBytes) {
|
||||
const err = new Error(`File too large. Maximum size is ${Math.floor(maxFileSizeBytes / (1024 * 1024))} MB per file.`);
|
||||
err.code = 'FILE_TOO_LARGE';
|
||||
err.statusCode = 413;
|
||||
return err;
|
||||
}
|
||||
|
||||
function invalidChunkError(message) {
|
||||
const err = new Error(message);
|
||||
err.code = 'INVALID_CHUNK';
|
||||
err.statusCode = 400;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new chunked upload
|
||||
* @param {Object} options - Upload options
|
||||
@@ -27,7 +48,8 @@ async function initializeUpload(options) {
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
totalChunks
|
||||
totalChunks,
|
||||
maxFileSizeBytes
|
||||
} = options;
|
||||
|
||||
// Strip any directory components from the client-supplied filename. It is
|
||||
@@ -50,6 +72,13 @@ async function initializeUpload(options) {
|
||||
// Calculate expected chunks
|
||||
const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE);
|
||||
|
||||
// The per-file cap is enforced on the BYTES ACTUALLY RECEIVED, not on the
|
||||
// client-declared fileSize the init route checks: a client can declare
|
||||
// `fileSize: 1` and then stream whatever it likes through the chunk route.
|
||||
// Missing/invalid cap means "no cap" (callers outside the admin routes).
|
||||
const cap = Number(maxFileSizeBytes);
|
||||
const sizeCap = Number.isFinite(cap) && cap > 0 ? cap : Infinity;
|
||||
|
||||
// Store upload metadata
|
||||
const uploadMeta = {
|
||||
uploadId,
|
||||
@@ -59,6 +88,9 @@ async function initializeUpload(options) {
|
||||
eventId,
|
||||
expectedChunks,
|
||||
receivedChunks: new Set(),
|
||||
// Bytes per chunk index, so a re-sent chunk replaces rather than adds.
|
||||
chunkSizes: new Map(),
|
||||
maxFileSizeBytes: sizeCap,
|
||||
uploadDir,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + UPLOAD_EXPIRATION_MS,
|
||||
@@ -107,12 +139,28 @@ async function uploadChunk(uploadId, chunkIndex, chunkData) {
|
||||
throw new Error('Upload expired');
|
||||
}
|
||||
|
||||
// Only the announced chunk indices are valid — anything else would merge
|
||||
// into nothing (a gap) or let more chunks in than the declared file has.
|
||||
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= uploadMeta.expectedChunks) {
|
||||
throw invalidChunkError(`Invalid chunk index ${chunkIndex}: expected 0-${uploadMeta.expectedChunks - 1}`);
|
||||
}
|
||||
|
||||
// Enforce the per-file cap on the running byte total. The upload is
|
||||
// aborted, not just rejected: the chunks on disk are already over the
|
||||
// limit and the client can't complete the file any more.
|
||||
const receivedBytes = totalReceivedBytes(uploadMeta) - (uploadMeta.chunkSizes.get(chunkIndex) || 0) + chunkData.length;
|
||||
if (receivedBytes > uploadMeta.maxFileSizeBytes) {
|
||||
await abortUpload(uploadId);
|
||||
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
|
||||
}
|
||||
|
||||
// Write chunk to disk
|
||||
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
|
||||
await fs.writeFile(chunkPath, chunkData);
|
||||
|
||||
// Mark chunk as received
|
||||
uploadMeta.receivedChunks.add(chunkIndex);
|
||||
uploadMeta.chunkSizes.set(chunkIndex, chunkData.length);
|
||||
|
||||
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;
|
||||
|
||||
@@ -184,6 +232,15 @@ async function completeUpload(uploadId) {
|
||||
});
|
||||
}
|
||||
|
||||
// Backstop for the per-chunk running total above: the merged file is
|
||||
// the number that matters, so it is the number that is checked last.
|
||||
if (stats.size > uploadMeta.maxFileSizeBytes) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true }).catch(() => {});
|
||||
activeUploads.delete(uploadId);
|
||||
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
|
||||
}
|
||||
|
||||
// Clean up chunks
|
||||
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user