From 1e38d84808ee2a2b176c75d5ec4975fba710e63c Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:41:27 +0200 Subject: [PATCH] fix(uploads): apply configured max file size to guest uploads (#613 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../uploadSettingsMaxFileSize.test.js | 65 +++++++++++++++++++ backend/src/routes/gallery.js | 22 ++++++- backend/src/routes/publicSettings.js | 11 ++++ backend/src/services/uploadSettings.js | 60 +++++++++++++++++ .../components/gallery/UserPhotoUpload.tsx | 16 +++-- frontend/src/i18n/locales/de.json | 5 +- frontend/src/i18n/locales/en.json | 5 +- frontend/src/i18n/locales/es.json | 2 +- frontend/src/i18n/locales/fr.json | 2 +- frontend/src/i18n/locales/nl.json | 2 +- frontend/src/i18n/locales/pt.json | 2 +- frontend/src/i18n/locales/ru.json | 2 +- frontend/src/i18n/locales/sl.json | 2 +- 13 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 backend/__tests__/services/uploadSettingsMaxFileSize.test.js diff --git a/backend/__tests__/services/uploadSettingsMaxFileSize.test.js b/backend/__tests__/services/uploadSettingsMaxFileSize.test.js new file mode 100644 index 00000000..671c3978 --- /dev/null +++ b/backend/__tests__/services/uploadSettingsMaxFileSize.test.js @@ -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 +}); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 13e206e6..adcdee5a 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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 }); } diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 4c440c19..70d5c49a 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -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, diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js index e438748e..06319cca 100644 --- a/backend/src/services/uploadSettings.js +++ b/backend/src/services/uploadSettings.js @@ -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 }; diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 955f3b18..0f4ffef9 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -43,6 +43,14 @@ export const UserPhotoUpload: React.FC = ({ ? 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 = ({ 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 = ({ {/* #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 })}