fix(upload): enforce the configured per-file size limit on admin uploads

getMaxFileSizeBytes() (general_max_file_size_mb, default 50MB) was only read
by adminSettings.js to display the value. The admin upload routes streamed
against a hardcoded ceiling instead, so the dropzone's "max. 50MB pro Datei"
was never enforced:

- adminPhotos.js POST /:eventId/upload    -> 10GB hardcoded
- adminPhotos.js POST /:eventId/chunked-upload/init -> 10GB hardcoded
- v1/events.js   POST /events/:id/photos  -> 100MB hardcoded

Resolve the cap per request (it is admin-configurable at runtime) and build
the multer instance from it, mirroring what gallery.js and adminTransfers.js
already do. The 400 names the configured limit and reuses gallery.js's exact
error string so the frontend surfaces it identically. getMaxFileSizeBytes()
clamps to MAX_ALLOWED_FILE_SIZE_MB, so the 10GB hard ceiling still bounds
everything.

gallery.js (guest upload) already enforced this correctly and is unchanged --
the report's claim that it did not is stale.

Interpretation: general_max_file_size_mb is a single per-file cap with no
photo/video split, and gallery.js already applies it blanket to guest video
uploads, so admin video uploads now share it too. On a default install that
means a 200MB video needs the setting raised first -- which is what the UI
has been advertising all along.

Refs testplan REPORT.md #1 (Part 7.06).
This commit is contained in:
Paul Nothaft
2026-09-01 16:23:04 +02:00
parent afaa00f428
commit e18ab0d842
4 changed files with 211 additions and 14 deletions
@@ -0,0 +1,148 @@
/**
* Per-file upload size limit on the admin photo routes.
*
* `general_max_file_size_mb` (Settings → General, default 50MB) is what the
* dropzone advertises ("max. 50MB per file"), but the admin upload route
* hardcoded multer's cap at 10GB and the chunked-upload init route at 10GB
* too — so the advertised limit was never enforced anywhere server-side and a
* 50.74MB JPEG uploaded cleanly.
*
* Pins:
* - a file over the configured cap is rejected with a 400 naming the limit
* - the chunked-upload init route honours the same cap (it would otherwise
* be a trivial bypass of the multipart route's cap)
* - a file under the cap still gets past the size gate
* - the limit is read per request, so an admin raising it takes effect
*/
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-upload-size-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'upload-size-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-upload-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 = 'upload-size-test-event';
describe('admin upload per-file size limit (general_max_file_size_mb)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
let uploadSettings;
const setLimitMb = async (mb) => {
await db('app_settings')
.insert({
setting_key: 'general_max_file_size_mb',
setting_value: JSON.stringify(mb),
setting_type: 'general',
updated_at: new Date().toISOString(),
})
.onConflict('setting_key')
.merge({ setting_value: JSON.stringify(mb) });
uploadSettings.clearMaxFileSizeCache();
};
const postUpload = (bytes, filename = 'shot.jpg') => request(app)
.post(`/api/admin/photos/${eventId}/upload`)
.set('Authorization', `Bearer ${adminToken}`)
.attach('photos', Buffer.alloc(bytes, 0x41), { filename, contentType: 'image/jpeg' });
const postChunkedInit = (fileSize) => request(app)
.post(`/api/admin/photos/${eventId}/chunked-upload/init`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ filename: 'clip.mp4', fileSize, mimeType: 'video/mp4', totalChunks: 1 });
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Upload 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: 'upload-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: 'upload-size-admin',
email: '[email protected]',
password_hash: await bcrypt.hash('UploadSize123', 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: 'upload-size-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
uploadSettings = require('../../src/services/uploadSettings');
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a file over the configured limit with a 400 naming the limit', async () => {
await setLimitMb(1);
const res = await postUpload(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 an over-limit chunked upload at init instead of allowing 10GB', async () => {
await setLimitMb(1);
const res = await postChunkedInit(200 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('lets a file under the limit past the size gate', async () => {
await setLimitMb(1);
// Junk bytes, so it still fails downstream on the content check — that is
// the point: the failure is no longer about size.
const res = await postUpload(64 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File content does not match declared type: shot.jpg');
});
it('reads the limit per request, so raising it takes effect immediately', async () => {
await setLimitMb(1);
expect((await postUpload(2 * 1024 * 1024)).status).toBe(400);
await setLimitMb(10);
const res = await postUpload(2 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File content does not match declared type: shot.jpg');
});
});