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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user