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:
@@ -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
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"noCategory": "Без категории",
|
||||
"eventSpecific": "(Для конкретного события)",
|
||||
"clickToUpload": "Нажмите для загрузки или перетащите файлы",
|
||||
"fileRequirements": "JPEG, PNG или WebP (макс. 50 МБ на файл, {{limit}} файлов за загрузку)",
|
||||
"fileRequirements": "JPEG, PNG или WebP (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
|
||||
"selectedFiles": "Выбранные файлы",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadComplete": "Загрузка завершена!",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user