fix(upload): scope category ids, stop temp-file leaks, split the video cap

Four related fixes on the admin upload/photo path.

B5 -- PATCH /photos/:photoId and POST /photos/bulk-update took any
parseInt(...) > 0 straight into the update with no existence or scope check,
so a photo could be moved into another event's category. The upload route
already validated `event_id = X OR is_global` per #500/#525; extracted that
query as findScopedCategory() and used it on all three routes so the 400 body
is byte-identical. 0/negative/'individual'/'collage'/null still clear without
a lookup, so the clear path costs no extra query.

B9 -- three distinct temp-file leaks, not one. The validator's size branch
never unlinked; the cleanup lived in the final handler, unreachable on any
400; and multer's `destination` callback runs per file and overwrote
req.tempUploadPath, so even the success path only ever removed the last
file's directory. Now: discardUploadedFiles() runs on every 4xx and the 500
(ENOENT tolerated, and files are only dropped when the whole request is being
rejected, so the passing path is untouched); cleanup registered before multer
so it also covers multer's own LIMIT_FILE_SIZE return; one directory per
request.

B8 -- the admin uploader filtered on MIME only, so an oversized file was
uploaded in full before the server's 400. Mirrors UserPhotoUpload's existing
per-file toast-and-drop.

C4 -- general_max_file_size_mb was a single cap for photos and videos, so the
50MB default meant admins could not upload ordinary video without also
raising the photo limit. Adds general_max_video_size_mb (default 500MB,
clamped by the same 10GB MAX_ALLOWED_FILE_SIZE_MB ceiling, read per request,
60s cache), editable in Settings -> General.

Photo uploads are protected from regressing by keeping multer's type-blind
limit at max(photoCap, videoCap) and moving the per-kind decision into
validateUploadContent, where file.mimetype exists. It 400s with the existing
message shape, so an oversized photo is still rejected with the identical
body it produced when multer did the rejecting.

Known gap: chunked-upload/init still applies the photo cap to video. Making
it video-aware would change an existing assertion that pins a 200MB video
init being rejected under a 1MB general cap. No component calls that path
today and the direction is strict rather than a bypass, so it is left as-is.
Guest video uploads still share the single cap in gallery.js.

Refs testplan REPORT.md B5, B8, B9, C4.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent 57dd084763
commit 7c9baff751
12 changed files with 804 additions and 70 deletions
+41 -19
View File
@@ -223,50 +223,72 @@ function createFileUploadValidator(options = {}) {
const {
allowedTypes = ['image/jpeg', 'image/png', 'image/webp'],
maxFileSize = 50 * 1024 * 1024, // 50MB default
// Videos carry their own per-file cap (general_max_video_size_mb).
// Defaults to the photo cap so callers that don't split the two behave
// exactly as before.
maxVideoFileSize = maxFileSize,
validateContent = true
} = options;
const isVideoType = (mimetype) => typeof mimetype === 'string' && mimetype.startsWith('video/');
return async (req, res, next) => {
// Every exit below rejects the whole request, so nothing downstream will
// ever read what multer already wrote to disk. Drop those files here or
// they leak: the routes register their temp-dir cleanup for the success
// path, which a rejection never reaches.
const discardUploadedFiles = async () => {
await Promise.all((req.files || []).map(async (file) => {
if (!file.path) return;
try {
await fs.unlink(file.path);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.error('Error removing rejected upload:', err);
}
}
}));
};
try {
if (!req.files || req.files.length === 0) {
return next();
}
for (const file of req.files) {
// Validate file type
if (!validateFileType(file.originalname, file.mimetype, allowedTypes)) {
return res.status(400).json({
error: `Invalid file type: ${file.originalname}. Allowed types: ${allowedTypes.join(', ')}`
await discardUploadedFiles();
return res.status(400).json({
error: `Invalid file type: ${file.originalname}. Allowed types: ${allowedTypes.join(', ')}`
});
}
// Validate file size
if (file.size > maxFileSize) {
return res.status(400).json({
error: `File too large: ${file.originalname}. Maximum size: ${maxFileSize / 1024 / 1024}MB`
// Validate file size against the cap for this kind of file
const sizeLimit = isVideoType(file.mimetype) ? maxVideoFileSize : maxFileSize;
if (file.size > sizeLimit) {
await discardUploadedFiles();
return res.status(400).json({
error: `File too large: ${file.originalname}. Maximum size: ${sizeLimit / 1024 / 1024}MB`
});
}
// Validate file content if enabled
if (validateContent && file.path) {
const isValidContent = await validateFileContent(file.path, file.mimetype);
if (!isValidContent) {
// Remove the file if content doesn't match
try {
await fs.unlink(file.path);
} catch (err) {
logger.error('Error removing invalid file:', err);
}
return res.status(400).json({
error: `File content does not match declared type: ${file.originalname}`
await discardUploadedFiles();
return res.status(400).json({
error: `File content does not match declared type: ${file.originalname}`
});
}
}
}
next();
} catch (error) {
logger.error('File validation error:', error);
await discardUploadedFiles();
res.status(500).json({ error: 'File validation failed' });
}
};