fix(upload): restore configurable batch-size for reverse proxies (#509)

Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.

Re-applying exactly the same change set:

- `backend/migrations/core/072_add_max_upload_batch_size.js`
  recreated, with a comment pointing at the regression in case the
  same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
  the setting from query cache and falls back to 95MB (Cloudflare-safe
  headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
  added the field to the state type + defaults + load path + the
  Site-Configuration input.

Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
  filename, so the runner skips re-applying. The setting row in
  `app_settings` is also untouched (the deletion was source-only, no
  down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
  072 normally and seeds the setting at 95.
This commit is contained in:
Paul Nothaft
2026-05-17 00:42:22 +02:00
parent 33de294d57
commit 98f3c3df41
6 changed files with 75 additions and 2 deletions
@@ -0,0 +1,38 @@
/**
* Migration: re-add the configurable upload batch size setting (#509).
*
* Originally shipped via PR #214 (#208 fix) — users behind Cloudflare
* Tunnel and other reverse proxies with per-request size caps need to
* bound the chunked-upload size so they don't lose every batch >100MB.
* That migration + frontend wiring was lost during a `Merge main into
* beta for release/beta-to-main` resolution that picked main's older
* tree over beta's, silently deleting the file and reinstating the
* hardcoded 500MB chunk in PhotoUpload.tsx.
*
* Re-introducing the exact same migration here. Idempotent: skips the
* insert if the row already exists (e.g. installs that did go through
* the original 072 between #214 merge and the main-into-beta merge,
* where the migrations-table row was preserved even after the file
* was deleted).
*/
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();
};
@@ -163,9 +163,14 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setUploadProgress(0); setUploadProgress(0);
setUploadIds([]); setUploadIds([]);
// For large uploads, chunk the files by both count AND size to prevent memory/network issues // For large uploads, chunk the files by both count AND size to prevent memory/network issues.
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
// reverse proxies with request-size limits can drop it below their proxy's cap. Falls back
// to 95MB (Cloudflare-safe headroom under 100MB) when the setting is unset — that matches
// the value the migration seeds and is what worked in #208's resolution.
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk 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[][] = []; const chunks: File[][] = [];
let currentChunk: File[] = []; let currentChunk: File[] = [];
@@ -16,6 +16,8 @@ export interface GeneralSettings {
max_file_size_mb: number; max_file_size_mb: number;
max_files_per_upload: number; max_files_per_upload: number;
allowed_file_types: string; allowed_file_types: string;
// #509 — re-added after the main-into-beta merge dropped it.
max_upload_batch_size_mb: number;
enable_analytics: boolean; enable_analytics: boolean;
enable_registration: boolean; enable_registration: boolean;
maintenance_mode: boolean; maintenance_mode: boolean;
@@ -91,6 +93,7 @@ export function useSettingsState() {
max_file_size_mb: 50, max_file_size_mb: 50,
max_files_per_upload: 500, max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp', allowed_file_types: 'jpg,jpeg,png,gif,webp',
max_upload_batch_size_mb: 95,
enable_analytics: true, enable_analytics: true,
enable_registration: false, enable_registration: false,
maintenance_mode: false, maintenance_mode: false,
@@ -177,6 +180,7 @@ export function useSettingsState() {
Math.max(1, toNumber(settings.general_max_files_per_upload, 500)) Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
), ),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp', 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_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false), enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false), maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
@@ -162,6 +162,28 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })} {t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p> </p>
</div> </div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.maxUploadBatchSize')}
</label>
<Input
type="number"
value={generalSettings.max_upload_batch_size_mb}
onChange={(e) => {
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"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.maxUploadBatchSizeHelp')}
</p>
</div>
</div> </div>
<div> <div>
+2
View File
@@ -1031,6 +1031,8 @@
"maxFileSize": "Max. Dateigröße (MB)", "maxFileSize": "Max. Dateigröße (MB)",
"maxFilesPerUpload": "Max. Dateien pro Upload", "maxFilesPerUpload": "Max. Dateien pro Upload",
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).", "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", "allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen", "allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter", "featureToggles": "Funktionsschalter",
+2
View File
@@ -670,6 +670,8 @@
"maxFileSize": "Max File Size (MB)", "maxFileSize": "Max File Size (MB)",
"maxFilesPerUpload": "Max Files per Upload", "maxFilesPerUpload": "Max Files per Upload",
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).", "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", "allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions", "allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles", "featureToggles": "Feature Toggles",