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