fix(uploads): apply configured max file size to guest uploads (#613 follow-up)

The admin's Settings → General → "Max File Size (MB)" value
(general_max_file_size_mb) never applied to guest gallery uploads — the guest
route hardcoded multer's per-file cap at 50MB (gallery.js) and the guest UI
hardcoded the same 50MB client-side guard and "max 50MB" hint text. So a guest
could not upload a large video even when the admin raised the limit (reported by
mat1990dj on #613). Same class as the file-count miss fixed in #614, for size.

- uploadSettings.js: new getMaxFileSizeMb()/getMaxFileSizeBytes() reading
  general_max_file_size_mb (default 50MB, cached 60s, clamped to a 10GB ceiling),
  mirroring getMaxFilesPerUpload.
- gallery.js (guest upload): multer limits.fileSize now resolves from the
  setting; a LIMIT_FILE_SIZE error returns an actionable "max N MB" message.
- publicSettings.js: exposes general_max_file_size_mb (default 50) so the gallery
  UI can render the real limit and guard client-side before an oversized POST.
- UserPhotoUpload.tsx: reads the limit, uses it for the client-side size guard,
  and passes it to the requirements hint. The "max 50MB" literal in
  upload.fileRequirements is now interpolated ({{sizeLimit}}) across all 8
  locales; adds upload.fileTooLarge (en/de; others fall back to en).

Scope: guest path only (the reported gap). The admin path keeps its generous
10GB cap — admins are trusted and default 50MB would otherwise regress large
admin video uploads. Format and batch-size limits already work correctly and are
untouched. Adds SQLite-backed unit tests for the new getter.

Verified end-to-end on a booted instance: admin sets 500MB → persisted → public
settings exposes 500 → guest multer sources its cap from it.
This commit is contained in:
Paul Nothaft
2026-07-17 20:41:27 +02:00
parent 1b32d4691e
commit 1e38d84808
13 changed files with 180 additions and 16 deletions
@@ -0,0 +1,65 @@
/**
* Unit tests for the per-file upload size limit getter (general_max_file_size_mb),
* added so the admin's "Max File Size (MB)" setting applies to guest uploads
* (#613 follow-up — mat1990dj). Real in-memory SQLite app_settings so the
* read/parse/cache path runs exactly as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/uploadSettings');
svc.clearMaxFileSizeCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
async function setLimit(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() })
.onConflict('setting_key').merge({ setting_value: JSON.stringify(mb) });
svc.clearMaxFileSizeCache();
}
test('defaults to 50MB when the setting is absent', async () => {
expect(await svc.getMaxFileSizeMb()).toBe(50);
expect(await svc.getMaxFileSizeBytes()).toBe(50 * 1024 * 1024);
});
test('honours a configured value (e.g. 500MB video)', async () => {
await setLimit(500);
expect(await svc.getMaxFileSizeMb()).toBe(500);
expect(await svc.getMaxFileSizeBytes()).toBe(500 * 1024 * 1024);
});
test('clamps a nonsense value to the default and caps absurd values at the ceiling', async () => {
await setLimit(0);
expect(await svc.getMaxFileSizeMb()).toBe(50); // 0 → default
await setLimit(99_999_999);
expect(await svc.getMaxFileSizeMb()).toBe(svc.MAX_ALLOWED_FILE_SIZE_MB); // ceiling
});
test('caches for the TTL — a mid-window DB change is not seen until the cache is cleared', async () => {
await setLimit(200);
expect(await svc.getMaxFileSizeMb()).toBe(200);
// change the DB but do NOT clear cache
await db('app_settings').where({ setting_key: 'general_max_file_size_mb' }).update({ setting_value: JSON.stringify(300) });
expect(await svc.getMaxFileSizeMb()).toBe(200); // still cached
svc.clearMaxFileSizeCache();
expect(await svc.getMaxFileSizeMb()).toBe(300); // refreshed
});
+20 -2
View File
@@ -1833,7 +1833,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
// Import multer and photo processing
const multer = require('multer');
const { getAllowedMimeTypes, getMaxFilesPerUpload } = require('../services/uploadSettings');
const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../services/uploadSettings');
const { validateFileType } = require('../utils/fileSecurityUtils');
// Resolve allowed MIME types from settings
@@ -1859,10 +1859,22 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
maxFilesPerUpload = 500;
}
// Per-file size cap was hardcoded to 50MB here, so the admin's Settings →
// General → "Max File Size (MB)" value (general_max_file_size_mb) never
// applied to guest uploads — a guest could not upload a large video even
// when the admin allowed it (reported on #613 by mat1990dj). Resolve it from
// settings like the count above; fall back to the 50MB default on read error.
let maxFileSizeBytes;
try {
maxFileSizeBytes = await getMaxFileSizeBytes();
} catch {
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;
}
const upload = multer({
dest: tempUploadDir,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB per file (separate concern from #613)
fileSize: maxFileSizeBytes,
files: maxFilesPerUpload
},
fileFilter: (req, file, cb) => {
@@ -1878,6 +1890,12 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
upload(req, res, async (err) => {
if (err) {
logger.error('Upload error:', err);
// Turn multer's generic "File too large" into an actionable message
// that names the configured limit.
if (err.code === 'LIMIT_FILE_SIZE') {
const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024));
return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` });
}
return res.status(400).json({ error: err.message });
}
+11
View File
@@ -28,6 +28,11 @@ router.get('/', async (req, res) => {
// but a client-side guard saves a 4MB+ round-trip when the
// limit is small.
'general_max_files_per_upload',
// Same rationale for the per-file size limit — the gallery upload
// component renders it in the requirements hint and guards
// client-side before posting an oversized file. Backend enforces
// via getMaxFileSizeBytes regardless.
'general_max_file_size_mb',
// #798 — the admin login page needs to know whether to show
// the "Sign in with SSO" button (and its label). Only these
// two oidc_* keys are public; issuer/client stay admin-only.
@@ -207,6 +212,12 @@ router.get('/', async (req, res) => {
general_max_files_per_upload: Number.isFinite(Number(settingsObject.general_max_files_per_upload))
? Number(settingsObject.general_max_files_per_upload)
: 500,
// Per-file size limit (MB). Default mirrors uploadSettings.js
// DEFAULT_MAX_FILE_SIZE_MB so the gallery UI shows a sensible number on
// installs that never set it explicitly.
general_max_file_size_mb: Number.isFinite(Number(settingsObject.general_max_file_size_mb))
? Number(settingsObject.general_max_file_size_mb)
: 50,
// SEO meta tag flags (safe to expose - these are intended for crawlers)
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
+60
View File
@@ -5,9 +5,18 @@ const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
const CACHE_TTL_MS = 60_000;
// Per-file upload size limit (general_max_file_size_mb). The admin sets this in
// Settings → General; the default mirrors the frontend's default (50 MB). A
// hard ceiling keeps a fat-fingered value from disabling multer's guard.
const DEFAULT_MAX_FILE_SIZE_MB = 50;
const MAX_ALLOWED_FILE_SIZE_MB = 10 * 1024; // 10 GB — matches the admin path's cap
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
let cacheExpiresAt = 0;
let cachedFileSizeMb = DEFAULT_MAX_FILE_SIZE_MB;
let fileSizeCacheExpiresAt = 0;
// Map of file extension to MIME type(s)
const EXTENSION_TO_MIME = {
'jpg': 'image/jpeg',
@@ -99,6 +108,52 @@ const clearMaxFilesPerUploadCache = () => {
cacheExpiresAt = 0;
};
const normalizeFileSizeMb = (value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return DEFAULT_MAX_FILE_SIZE_MB;
}
const intValue = Math.floor(value);
if (intValue < 1) return DEFAULT_MAX_FILE_SIZE_MB;
if (intValue > MAX_ALLOWED_FILE_SIZE_MB) return MAX_ALLOWED_FILE_SIZE_MB;
return intValue;
};
/**
* Per-file upload size limit in MB (general_max_file_size_mb). Cached 60s, same
* as the other upload settings. Falls back to the default on a read error.
*/
const getMaxFileSizeMb = async () => {
if (Date.now() < fileSizeCacheExpiresAt) {
return cachedFileSizeMb;
}
try {
const setting = await db('app_settings')
.where({ setting_key: 'general_max_file_size_mb' })
.first();
const parsedValue = normalizeFileSizeMb(parseSettingValue(setting));
cachedFileSizeMb = parsedValue;
fileSizeCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return parsedValue;
} catch (error) {
logger.error('Failed to read max file size setting:', error.message);
cachedFileSizeMb = DEFAULT_MAX_FILE_SIZE_MB;
fileSizeCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return DEFAULT_MAX_FILE_SIZE_MB;
}
};
/** Per-file upload size limit in bytes — convenience for multer `limits.fileSize`. */
const getMaxFileSizeBytes = async () => {
const mb = await getMaxFileSizeMb();
return mb * 1024 * 1024;
};
const clearMaxFileSizeCache = () => {
fileSizeCacheExpiresAt = 0;
};
/**
* Convert a comma-separated list of file extensions into an array of MIME types.
* Unknown extensions are silently ignored.
@@ -164,11 +219,16 @@ const clearAllowedTypesCache = () => {
module.exports = {
getMaxFilesPerUpload,
clearMaxFilesPerUploadCache,
getMaxFileSizeMb,
getMaxFileSizeBytes,
clearMaxFileSizeCache,
getAllowedMimeTypes,
clearAllowedTypesCache,
extensionsToMimeTypes,
EXTENSION_TO_MIME,
DEFAULT_MAX_FILES_PER_UPLOAD,
MAX_ALLOWED_FILES_PER_UPLOAD,
DEFAULT_MAX_FILE_SIZE_MB,
MAX_ALLOWED_FILE_SIZE_MB,
DEFAULT_ALLOWED_FILE_TYPES
};
@@ -43,6 +43,14 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
? Number(publicSettings?.general_max_files_per_upload)
: 500;
// Per-file size limit (MB). Was hardcoded to 50MB below, so the admin's
// "Max File Size" setting never applied to guests (#613 follow-up). Surfaced
// via publicSettings (default 50); the backend enforces the same value.
const maxFileSizeMb = Number.isFinite(Number(publicSettings?.general_max_file_size_mb))
? Number(publicSettings?.general_max_file_size_mb)
: 50;
const maxFileSizeBytes = maxFileSizeMb * 1024 * 1024;
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
@@ -60,9 +68,9 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
toast.error(`Invalid file type: ${file.name}`);
return false;
}
// Check file size (50MB max)
if (file.size > 50 * 1024 * 1024) {
toast.error(`File too large: ${file.name}`);
// Check file size against the configured per-file limit.
if (file.size > maxFileSizeBytes) {
toast.error(t('upload.fileTooLarge', { name: file.name, limit: maxFileSizeMb }));
return false;
}
return true;
@@ -228,7 +236,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* #613 — pass { limit } so `{{limit}}` interpolates
with the real number from settings instead of
rendering literally. */}
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
{t('upload.fileRequirements', { limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
</p>
<input
type="file"
+3 -2
View File
@@ -159,7 +159,7 @@
"noCategory": "Keine Kategorie",
"eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
"fileRequirements": "JPEG, PNG oder WebP (max. {{sizeLimit}}MB pro Datei, {{limit}} Dateien pro Upload)",
"selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...",
"transferring": "Übertragung",
@@ -192,7 +192,8 @@
"processingFailed_one": "{{count}} Foto konnte nicht verarbeitet werden",
"processingFailed_other": "{{count}} Fotos konnten nicht verarbeitet werden",
"uploadingChunks_one": "{{count}} Teil wird hochgeladen",
"uploadingChunks_other": "{{count}} Teile werden hochgeladen"
"uploadingChunks_other": "{{count}} Teile werden hochgeladen",
"fileTooLarge": "Datei zu groß: {{name}} (max. {{limit}}MB)"
},
"navigation": {
"dashboard": "Dashboard",
+3 -2
View File
@@ -159,7 +159,7 @@
"noCategory": "No category",
"eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
"fileRequirements": "JPEG, PNG or WebP (max {{sizeLimit}}MB per file, {{limit}} files per upload)",
"selectedFiles": "Selected files",
"uploading": "Uploading...",
"transferring": "Transferring",
@@ -192,7 +192,8 @@
"processingFailed_one": "{{count}} photo failed to process",
"processingFailed_other": "{{count}} photos failed to process",
"uploadingChunks_one": "{{count}} chunk uploading",
"uploadingChunks_other": "{{count}} chunks uploading"
"uploadingChunks_other": "{{count}} chunks uploading",
"fileTooLarge": "File too large: {{name}} (max {{limit}}MB)"
},
"navigation": {
"dashboard": "Dashboard",
+1 -1
View File
@@ -126,7 +126,7 @@
"noCategory": "Sin categoría",
"eventSpecific": "(Específico del evento)",
"clickToUpload": "Haz clic para subir o arrastra y suelta",
"fileRequirements": "JPEG, PNG o WebP (máx. 50MB por archivo, {{limit}} archivos por subida)",
"fileRequirements": "JPEG, PNG o WebP (máx. {{sizeLimit}}MB por archivo, {{limit}} archivos por subida)",
"fileRequirementsMedia": "Imágenes JPEG, PNG o WebP y videos MP4/MOV/WEBM (máx. 50MB por archivo, {{limit}} archivos por subida)",
"unsupportedFiles": "Algunos archivos se omitieron porque el formato no es compatible (usa JPEG/PNG/WebP/MP4/MOV/WEBM).",
"selectedFiles": "Archivos seleccionados",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Aucune catégorie",
"eventSpecific": "(Spécifique à l'événement)",
"clickToUpload": "Cliquez pour téléverser ou glissez-déposez",
"fileRequirements": "JPEG, PNG ou WebP (max 50 Mo par fichier, {{limit}} fichiers par téléversement)",
"fileRequirements": "JPEG, PNG ou WebP (max {{sizeLimit}} Mo par fichier, {{limit}} fichiers par téléversement)",
"selectedFiles": "Fichiers sélectionnés",
"uploading": "Téléversement en cours...",
"transferring": "Transfert en cours",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Geen categorie",
"eventSpecific": "(Evenement-specifiek)",
"clickToUpload": "Klik om te uploaden of sleep bestanden hierheen",
"fileRequirements": "JPEG, PNG of WebP (max. 50 MB per bestand, {{limit}} bestanden per upload)",
"fileRequirements": "JPEG, PNG of WebP (max. {{sizeLimit}} MB per bestand, {{limit}} bestanden per upload)",
"selectedFiles": "Geselecteerde bestanden",
"uploading": "Uploaden...",
"uploadComplete": "Upload voltooid!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Sem categoria",
"eventSpecific": "(Específico do evento)",
"clickToUpload": "Clique para enviar ou arraste e solte",
"fileRequirements": "JPEG, PNG ou WebP (máx. 50MB por arquivo, {{limit}} arquivos por envio)",
"fileRequirements": "JPEG, PNG ou WebP (máx. {{sizeLimit}}MB por arquivo, {{limit}} arquivos por envio)",
"selectedFiles": "Arquivos selecionados",
"uploading": "Enviando...",
"uploadComplete": "Envio concluído!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Без категории",
"eventSpecific": "(Для конкретного события)",
"clickToUpload": "Нажмите для загрузки или перетащите файлы",
"fileRequirements": "JPEG, PNG или WebP (макс. 50 МБ на файл, {{limit}} файлов за загрузку)",
"fileRequirements": "JPEG, PNG или WebP (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
"selectedFiles": "Выбранные файлы",
"uploading": "Загрузка...",
"uploadComplete": "Загрузка завершена!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Brez kategorije",
"eventSpecific": "(specifično za dogodek)",
"clickToUpload": "Kliknite za nalaganje ali povlecite in spustite",
"fileRequirements": "JPEG, PNG ali WebP (največ 50 MB na datoteko, {{limit}} datotek na nalaganje)",
"fileRequirements": "JPEG, PNG ali WebP (največ {{sizeLimit}} MB na datoteko, {{limit}} datotek na nalaganje)",
"selectedFiles": "Izbrane datoteke",
"uploading": "Nalaganje...",
"transferring": "Prenašanje",