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
@@ -0,0 +1,188 @@
/**
* Category scope on the admin photo update routes.
*
* The upload route validates a numeric category_id against
* (event_id = this event OR is_global) and 400s an out-of-scope id
* (#500 / #525), but PATCH /:eventId/photos/:photoId and
* POST /:eventId/photos/bulk-update accepted ANY positive id — so a photo
* could be filed under another event's category, where no grid filter
* (neither the category filters nor the whereNull "uncategorized" one)
* would ever show it again.
*
* Pins:
* - an id belonging to a different event is rejected with the same 400
* shape the upload route uses, on both routes
* - a global category and this event's own category are both accepted
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-cat-scope-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'cat-scope-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-cat-scope-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('admin photo category scope (PATCH / bulk-update)', () => {
let db;
let cleanup;
let app;
let eventId;
let otherEventId;
let photoId;
let adminToken;
let ownCategoryId;
let globalCategoryId;
let foreignCategoryId;
const unwrap = (rows) => {
const row = rows[0];
return typeof row === 'object' && row !== null ? row.id : row;
};
const seedEvent = async (slug) => {
const inserted = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Cat Scope ${slug}`,
event_date: '2026-09-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return unwrap(inserted);
};
const patchCategory = (categoryId) => request(app)
.patch(`/api/admin/photos/${eventId}/photos/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ category_id: categoryId });
const bulkUpdateCategory = (categoryId) => request(app)
.post(`/api/admin/photos/${eventId}/photos/bulk-update`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ photoIds: [photoId], updates: { category_id: categoryId } });
const storedCategoryId = async () => {
const row = await db('photos').where({ id: photoId }).first();
return row.category_id;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
eventId = await seedEvent('cat-scope-event');
otherEventId = await seedEvent('cat-scope-other-event');
// is_global defaults to TRUE on this table, so the event-scoped rows have
// to say so explicitly — otherwise every category is in scope everywhere.
ownCategoryId = unwrap(await db('photo_categories').insert({
event_id: eventId, name: 'Ceremony', slug: 'cs-ceremony', is_global: false, created_at: new Date().toISOString(),
}).returning('id'));
globalCategoryId = unwrap(await db('photo_categories').insert({
event_id: null, name: 'Portraits', slug: 'cs-portraits', is_global: true, created_at: new Date().toISOString(),
}).returning('id'));
foreignCategoryId = unwrap(await db('photo_categories').insert({
event_id: otherEventId, name: 'Reception', slug: 'cs-reception', is_global: false, created_at: new Date().toISOString(),
}).returning('id'));
photoId = unwrap(await db('photos').insert({
event_id: eventId,
filename: 'shot.jpg',
path: 'cat-scope-event/shot.jpg',
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id'));
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const rootId = unwrap(await db('admin_users').insert({
username: 'cat-scope-admin',
email: '[email protected]',
password_hash: await bcrypt.hash('CatScope123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id'));
adminToken = jwt.sign(
{ id: rootId, username: 'cat-scope-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photos').where({ id: photoId }).update({ category_id: null });
});
it('PATCH rejects a category belonging to another event', async () => {
const res = await patchCategory(foreignCategoryId);
expect(res.status).toBe(400);
expect(res.body.error).toBe(`Unknown or out-of-scope category_id ${foreignCategoryId}`);
expect(await storedCategoryId()).toBeNull();
});
it('PATCH rejects a category id that does not exist at all', async () => {
const res = await patchCategory(999999);
expect(res.status).toBe(400);
expect(res.body.error).toBe('Unknown or out-of-scope category_id 999999');
expect(await storedCategoryId()).toBeNull();
});
it('PATCH accepts this event\'s own category and a global one', async () => {
expect((await patchCategory(ownCategoryId)).status).toBe(200);
expect(await storedCategoryId()).toBe(ownCategoryId);
expect((await patchCategory(globalCategoryId)).status).toBe(200);
expect(await storedCategoryId()).toBe(globalCategoryId);
});
it('bulk-update rejects a category belonging to another event', async () => {
const res = await bulkUpdateCategory(foreignCategoryId);
expect(res.status).toBe(400);
expect(res.body.error).toBe(`Unknown or out-of-scope category_id ${foreignCategoryId}`);
expect(await storedCategoryId()).toBeNull();
});
it('bulk-update accepts this event\'s own category and a global one', async () => {
expect((await bulkUpdateCategory(ownCategoryId)).status).toBe(200);
expect(await storedCategoryId()).toBe(ownCategoryId);
expect((await bulkUpdateCategory(globalCategoryId)).status).toBe(200);
expect(await storedCategoryId()).toBe(globalCategoryId);
});
it('still clears the category for 0 / individual without a scope lookup', async () => {
await db('photos').where({ id: photoId }).update({ category_id: ownCategoryId });
expect((await patchCategory('0')).status).toBe(200);
expect(await storedCategoryId()).toBeNull();
await db('photos').where({ id: photoId }).update({ category_id: ownCategoryId });
expect((await bulkUpdateCategory('individual')).status).toBe(200);
expect(await storedCategoryId()).toBeNull();
});
});
@@ -0,0 +1,202 @@
/**
* Separate per-file cap for videos (general_max_video_size_mb) and temp-file
* cleanup on rejected uploads.
*
* `general_max_file_size_mb` was a single cap for photos AND videos, so with
* the 50MB default an admin could not upload a normal clip through the Photos
* tab without raising a limit that also governs photos. Videos now have their
* own cap; multer's (type-blind) limit is the larger of the two and the
* per-kind decision happens after multer, where the MIME type is known.
*
* Pins:
* - a video between the photo cap and the video cap gets past the size gate
* - a photo is still held to the photo cap even though multer streamed
* against the (larger) video cap
* - a video over the video cap is rejected naming the video cap
* - both caps are read per request
* - a rejected upload leaves nothing behind in the temp directory
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-size-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'video-size-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-video-size-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'video-size-test-event';
// Resolved lazily: bootCrmDb() repoints STORAGE_PATH at its own tmp dir, so a
// path captured at module load is not the one the route uploads into.
const tempRoot = () => path.join(process.env.STORAGE_PATH, 'temp');
describe('admin upload per-file video size limit (general_max_video_size_mb)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
let uploadSettings;
const setSetting = async (key, value) => {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'general',
updated_at: new Date().toISOString(),
})
.onConflict('setting_key')
.merge({ setting_value: JSON.stringify(value) });
};
const setCaps = async ({ photoMb, videoMb }) => {
await setSetting('general_max_file_size_mb', photoMb);
await setSetting('general_max_video_size_mb', videoMb);
uploadSettings.clearMaxFileSizeCache();
uploadSettings.clearMaxVideoSizeCache();
};
const postUpload = (bytes, filename, contentType) => request(app)
.post(`/api/admin/photos/${eventId}/upload`)
.set('Authorization', `Bearer ${adminToken}`)
.attach('photos', Buffer.alloc(bytes, 0x41), { filename, contentType });
const postVideo = (bytes) => postUpload(bytes, 'clip.mp4', 'video/mp4');
const postPhoto = (bytes) => postUpload(bytes, 'shot.jpg', 'image/jpeg');
// res.on('finish') cleanup is async, so give it a moment to land.
const tempEntriesAfterSettle = async () => {
let entries = [];
for (let i = 0; i < 40; i++) {
entries = fs.existsSync(tempRoot()) ? fs.readdirSync(tempRoot()) : [];
if (entries.length === 0) return entries;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return entries;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Video Size Test',
event_date: '2026-09-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'video-size-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'video-size-admin',
email: '[email protected]',
password_hash: await bcrypt.hash('VideoSize123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'video-size-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
uploadSettings = require('../../src/services/uploadSettings');
// mp4 has to be an allowed type for the video to reach the size gate.
await setSetting('general_allowed_file_types', 'jpg,jpeg,png,webp,mp4');
uploadSettings.clearAllowedTypesCache();
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lets a video past the size gate that the photo cap would have rejected', async () => {
await setCaps({ photoMb: 1, videoMb: 10 });
const res = await postVideo(2 * 1024 * 1024);
// Junk bytes, so it still fails on the content check — that is the point:
// the failure is no longer about size.
expect(res.status).toBe(400);
expect(res.body.error).toBe('File content does not match declared type: clip.mp4');
});
it('still holds a photo to the photo cap even though multer streamed against the video cap', async () => {
await setCaps({ photoMb: 1, videoMb: 10 });
const res = await postPhoto(2 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('rejects a video over the video cap, naming the video cap', async () => {
// Video cap deliberately BELOW the photo cap, so multer's limit is the
// photo cap and only the MIME-aware gate can reject this.
await setCaps({ photoMb: 10, videoMb: 2 });
const res = await postVideo(3 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File too large. Maximum size is 2 MB per file.');
});
it('reads the video cap per request, so raising it takes effect immediately', async () => {
await setCaps({ photoMb: 1, videoMb: 1 });
expect((await postVideo(2 * 1024 * 1024)).body.error)
.toBe('File too large. Maximum size is 1 MB per file.');
await setCaps({ photoMb: 1, videoMb: 10 });
const res = await postVideo(2 * 1024 * 1024);
expect(res.body.error).toBe('File content does not match declared type: clip.mp4');
});
it('defaults the video cap to 500MB when the setting is absent', async () => {
await db('app_settings').where({ setting_key: 'general_max_video_size_mb' }).del();
await setSetting('general_max_file_size_mb', 1);
uploadSettings.clearMaxFileSizeCache();
uploadSettings.clearMaxVideoSizeCache();
expect(await uploadSettings.getMaxVideoSizeMb()).toBe(500);
const res = await postVideo(2 * 1024 * 1024);
expect(res.body.error).toBe('File content does not match declared type: clip.mp4');
});
it('leaves no temp files behind when an upload is rejected', async () => {
await setCaps({ photoMb: 1, videoMb: 10 });
// Rejected on size (the MIME-aware gate)…
expect((await postPhoto(2 * 1024 * 1024)).status).toBe(400);
expect(await tempEntriesAfterSettle()).toEqual([]);
// …and rejected on content, with two files in the batch so the shared
// per-request temp directory is exercised.
const res = await request(app)
.post(`/api/admin/photos/${eventId}/upload`)
.set('Authorization', `Bearer ${adminToken}`)
.attach('photos', Buffer.alloc(1024, 0x41), { filename: 'a.jpg', contentType: 'image/jpeg' })
.attach('photos', Buffer.alloc(1024, 0x41), { filename: 'b.jpg', contentType: 'image/jpeg' });
expect(res.status).toBe(400);
expect(await tempEntriesAfterSettle()).toEqual([]);
});
});