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 },
+ });
+ });
+});