fix(gallery): guest upload honours general_max_files_per_upload + i18n placeholder interpolates (#613)

Zszywany reported on v3.44.0 (Ubuntu, Postgres 15): with Settings →
General → "Max Files per Upload" set to 10, an event configured with
allow_user_uploads + a guest selecting 16 files in the gallery's
"Upload Photos" modal succeeded silently — admin uploads to the same
event correctly refused with "Upload limit reached". On top of that,
the gallery modal's "fileRequirements" hint literally rendered
`{{limit}}` instead of the configured number.

Two separate misses for the guest path, both fixed here:

1. **Backend enforcement** — `backend/src/routes/gallery.js:1641` had
   `limits: { fileSize: 50MB, files: 10 }` and `.array('photos', 10)`
   hardcoded. The admin path at adminPhotos.js:131 has always resolved
   files-per-batch via `getMaxFilesPerUpload()` (cached 60s read of
   `general_max_files_per_upload`); guest path just never used it.
   Mirror the admin: `const maxFilesPerUpload = await getMaxFilesPerUpload()`
   and feed multer both `limits.files` AND the `.array(...)` cap. The
   50MB per-file size is a separate concern from this issue and stays
   as-is for now.

2. **i18n interpolation missing on the guest modal** —
   `UserPhotoUpload.tsx:203` called `t('upload.fileRequirements')` with
   no arguments. The translation string at `en.json:160` is
   "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)"
   — `{{limit}}` is unbound, so i18next emits it literally. The admin
   variant `PhotoUpload.tsx:414` correctly passes
   `{ limit: maxFilesPerUpload }`.

   Also wired up the same client-side count guard the admin component
   uses: addFiles refuses additions past the limit (`upload.limitReached`)
   and warns on partial-truncate (`upload.someFilesSkipped`). Backend
   enforces too, but the client guard saves a 4MB+ multipart POST when
   the user is clearly over.

To surface the setting on the guest side, `general_max_files_per_upload`
joins the public-settings whitelist + projection (publicSettings.js)
and the `PublicSettings` TS interface gets the new field. Default
fallback (500, matching `uploadSettings.js` DEFAULT_MAX_FILES_PER_UPLOAD)
in both backend projection and frontend reader so an install that's
never set the value renders a sensible number rather than "undefined".
This commit is contained in:
Paul Nothaft
2026-06-10 18:22:34 +02:00
parent 1d03a670e4
commit 69b5186582
4 changed files with 72 additions and 6 deletions
@@ -32,6 +32,17 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const { data: publicSettings } = usePublicSettings();
// #613 — guest upload UI was hardcoded to behave as if the limit was
// unlimited (no client-side guard) and the fileRequirements hint
// rendered `{{limit}}` literally because t() was called with no
// interpolation argument. publicSettings now surfaces
// general_max_files_per_upload (default 500 from publicSettings.js)
// so we can render a real number and refuse oversized batches before
// they hit the backend. Backend route enforces the same value too.
const maxFilesPerUpload = Number.isFinite(Number(publicSettings?.general_max_files_per_upload))
? Number(publicSettings?.general_max_files_per_upload)
: 500;
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
@@ -57,6 +68,20 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
return true;
});
if (validFiles.length === 0) return;
// #613 — per-batch file count guard. Mirrors what the admin's
// PhotoUpload component does. Backend also enforces, so this is
// purely UX (saves a multi-MB POST when the user clearly went over).
const remaining = Math.max(0, maxFilesPerUpload - files.length);
if (remaining === 0) {
toast.error(t('upload.limitReached', { limit: maxFilesPerUpload }));
return;
}
if (validFiles.length > remaining) {
toast.warning(t('upload.someFilesSkipped', { allowed: remaining, limit: maxFilesPerUpload }));
setFiles((prev) => [...prev, ...validFiles.slice(0, remaining)]);
return;
}
setFiles((prev) => [...prev, ...validFiles]);
};
@@ -200,7 +225,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{t('upload.clickToUpload')}
</p>
<p className="text-xs text-muted-theme">
{t('upload.fileRequirements')}
{/* #613 — pass { limit } so `{{limit}}` interpolates
with the real number from settings instead of
rendering literally. */}
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p>
<input
type="file"
@@ -70,6 +70,10 @@ export interface PublicSettings {
umami_share_url: string | null;
// Upload settings
allowed_file_types?: string;
// #613 — per-batch file count limit, surfaced so the guest UserPhotoUpload
// modal can render the real number in `upload.fileRequirements` and refuse
// oversized batches client-side. Backend enforces the same value too.
general_max_files_per_upload?: number;
// Event field requirements
event_require_customer_name?: boolean;
event_require_customer_email?: boolean;