From 02a46e083d68cfdb355b5a4fe4a8da7d667050b9 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 5 Mar 2026 22:12:37 +0100 Subject: [PATCH] feat: add configurable upload batch size for reverse proxy compatibility (#208) Users behind Cloudflare Tunnel and other reverse proxies cannot upload batches >100MB. The upload chunking previously used a hardcoded 500MB limit. This adds a configurable `max_upload_batch_size_mb` setting (default 95MB) to the admin General settings, leaving headroom below Cloudflare's 100MB limit. --- .../core/072_add_max_upload_batch_size.js | 20 ++++ frontend/src/components/admin/PhotoUpload.tsx | 3 +- .../settings/hooks/useSettingsState.ts | 3 + .../src/features/settings/tabs/GeneralTab.tsx | 22 ++++ frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + tests/e2e/upload-batch-size-setting.spec.ts | 104 ++++++++++++++++++ 7 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/core/072_add_max_upload_batch_size.js create mode 100644 tests/e2e/upload-batch-size-setting.spec.ts diff --git a/backend/migrations/core/072_add_max_upload_batch_size.js b/backend/migrations/core/072_add_max_upload_batch_size.js new file mode 100644 index 00000000..4f1cb45c --- /dev/null +++ b/backend/migrations/core/072_add_max_upload_batch_size.js @@ -0,0 +1,20 @@ +exports.up = async function(knex) { + const exists = await knex('app_settings') + .where({ setting_key: 'general_max_upload_batch_size_mb' }) + .first(); + + if (!exists) { + await knex('app_settings').insert({ + setting_key: 'general_max_upload_batch_size_mb', + setting_value: JSON.stringify(95), + setting_type: 'general', + updated_at: new Date() + }); + } +}; + +exports.down = async function(knex) { + await knex('app_settings') + .where({ setting_key: 'general_max_upload_batch_size_mb' }) + .del(); +}; diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index a0dd5d61..ae53557d 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -98,7 +98,8 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl // For large uploads, chunk the files by both count AND size to prevent memory/network issues const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk - const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB) + const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95; + const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024; const chunks: File[][] = []; let currentChunk: File[] = []; diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index ffae38fc..20a88e6e 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -16,6 +16,7 @@ export interface GeneralSettings { max_file_size_mb: number; max_files_per_upload: number; allowed_file_types: string; + max_upload_batch_size_mb: number; enable_analytics: boolean; enable_registration: boolean; maintenance_mode: boolean; @@ -87,6 +88,7 @@ export function useSettingsState() { max_file_size_mb: 50, max_files_per_upload: 500, allowed_file_types: 'jpg,jpeg,png,gif,webp', + max_upload_batch_size_mb: 95, enable_analytics: true, enable_registration: false, maintenance_mode: false, @@ -169,6 +171,7 @@ export function useSettingsState() { Math.max(1, toNumber(settings.general_max_files_per_upload, 500)) ), allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp', + max_upload_batch_size_mb: toNumber(settings.general_max_upload_batch_size_mb, 95), enable_analytics: toBoolean(settings.general_enable_analytics, true), enable_registration: toBoolean(settings.general_enable_registration, false), maintenance_mode: toBoolean(settings.general_maintenance_mode, false), diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index 964bbdbe..6c4ae1fd 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -161,6 +161,28 @@ export const GeneralTab: React.FC = ({ {t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}

+
+ + { + const parsed = parseInt(e.target.value, 10); + setGeneralSettings(prev => ({ + ...prev, + max_upload_batch_size_mb: Number.isFinite(parsed) + ? Math.max(1, parsed) + : prev.max_upload_batch_size_mb + })); + }} + min="1" + /> +

+ {t('settings.general.maxUploadBatchSizeHelp')} +

+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 33f8ac02..30066b41 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1012,6 +1012,8 @@ "maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto", "maxFilesPerUpload": "Max. Dateien pro Upload", "maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).", + "maxUploadBatchSize": "Max. Upload-Paketgröße (MB)", + "maxUploadBatchSizeHelp": "Maximale Größe pro Upload-Anfrage. Reduzieren Sie diesen Wert bei Nutzung eines Reverse-Proxys mit Größenbeschränkung (z.B. Cloudflare: 100MB).", "allowedFileTypes": "Erlaubte Dateitypen", "allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen", "featureToggles": "Funktionsschalter", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 89e0022f..eb1b9819 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -637,6 +637,8 @@ "maxFileSizeHelp": "Maximum size per uploaded photo", "maxFilesPerUpload": "Max Files per Upload", "maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).", + "maxUploadBatchSize": "Max Upload Batch Size (MB)", + "maxUploadBatchSizeHelp": "Maximum size per upload request. Lower this if behind a reverse proxy with request size limits (e.g. Cloudflare: 100MB).", "allowedFileTypes": "Allowed File Types", "allowedFileTypesHelp": "Comma-separated list of file extensions", "featureToggles": "Feature Toggles", diff --git a/tests/e2e/upload-batch-size-setting.spec.ts b/tests/e2e/upload-batch-size-setting.spec.ts new file mode 100644 index 00000000..a06bc8f4 --- /dev/null +++ b/tests/e2e/upload-batch-size-setting.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '@playwright/test'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; + +async function getAdminToken(page: import('@playwright/test').Page): Promise { + const loginRes = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + const body = await loginRes.json(); + return body.token; +} + +async function adminLogin(page: import('@playwright/test').Page) { + await page.goto('/admin/login'); + await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL); + await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click(); + await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 }); +} + +test.describe('Upload batch size setting', () => { + test('setting exists in DB via API with default value 95', async ({ page }) => { + const token = await getAdminToken(page); + + const res = await page.request.get('/api/admin/settings', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.ok()).toBeTruthy(); + const settings = await res.json(); + expect(settings.general_max_upload_batch_size_mb).toBe(95); + }); + + test('setting appears in General settings UI and can be changed', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Settings UI validated on desktop viewport'); + } + + await adminLogin(page); + await page.goto('/admin/settings'); + + // Find the batch size input by its nearby label text + const batchSizeLabel = page.locator('label', { hasText: /Max Upload Batch Size|Max\. Upload-Paketgröße/i }); + await expect(batchSizeLabel).toBeVisible({ timeout: 10000 }); + + // The input is a sibling within the same container + const batchSizeInput = batchSizeLabel.locator('..').locator('input[type="number"]'); + await expect(batchSizeInput).toBeVisible(); + await expect(batchSizeInput).toHaveValue('95'); + + // Change value to 50 + await batchSizeInput.fill('50'); + + // Save general settings + const saveButton = page.getByRole('button', { name: /Save General Settings|Allgemeine Einstellungen speichern/i }); + await saveButton.click(); + + // Wait for success toast + await expect(page.locator('.Toastify__toast').filter({ hasText: /(Settings saved|Einstellungen gespeichert)/i })).toBeVisible({ timeout: 10000 }); + + // Reload and verify persisted + await page.reload(); + const batchSizeLabelAfter = page.locator('label', { hasText: /Max Upload Batch Size|Max\. Upload-Paketgröße/i }); + await expect(batchSizeLabelAfter).toBeVisible({ timeout: 10000 }); + const batchSizeInputAfter = batchSizeLabelAfter.locator('..').locator('input[type="number"]'); + await expect(batchSizeInputAfter).toHaveValue('50'); + + // Revert to default + await batchSizeInputAfter.fill('95'); + await page.getByRole('button', { name: /Save General Settings|Allgemeine Einstellungen speichern/i }).click(); + await expect(page.locator('.Toastify__toast').filter({ hasText: /(Settings saved|Einstellungen gespeichert)/i })).toBeVisible({ timeout: 10000 }); + }); + + test('setting is used for upload chunking via API', async ({ page }) => { + const token = await getAdminToken(page); + + // Set batch size to a small value + const updateRes = await page.request.put('/api/admin/settings/general', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { general_max_upload_batch_size_mb: 10 }, + }); + expect(updateRes.ok()).toBeTruthy(); + + // Verify the setting was saved + const getRes = await page.request.get('/api/admin/settings', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(getRes.ok()).toBeTruthy(); + const settings = await getRes.json(); + expect(settings.general_max_upload_batch_size_mb).toBe(10); + + // Revert to default + await page.request.put('/api/admin/settings/general', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { general_max_upload_batch_size_mb: 95 }, + }); + }); +});