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
+19 -4
View File
@@ -1627,7 +1627,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
// Import multer and photo processing // Import multer and photo processing
const multer = require('multer'); const multer = require('multer');
const { getAllowedMimeTypes } = require('../services/uploadSettings'); const { getAllowedMimeTypes, getMaxFilesPerUpload } = require('../services/uploadSettings');
const { validateFileType } = require('../utils/fileSecurityUtils'); const { validateFileType } = require('../utils/fileSecurityUtils');
// Resolve allowed MIME types from settings // Resolve allowed MIME types from settings
@@ -1638,11 +1638,26 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
} }
// #613 — per-batch file count was hardcoded to 10 here, so the admin's
// Settings → General → "Max Files per Upload" value silently didn't
// apply to guest uploads (only admin uploads honoured it via
// adminPhotos.js:131). Zszywany reported uploading 16 files succeeded
// even with the limit set to 10. Mirror the admin path: resolve from
// settings (cached for 60s in the service) and feed multer both
// `limits.files` and the `.array(...)` cap. Fall back to the service's
// default if the read fails.
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
} catch {
maxFilesPerUpload = 500;
}
const upload = multer({ const upload = multer({
dest: tempUploadDir, dest: tempUploadDir,
limits: { limits: {
fileSize: 50 * 1024 * 1024, // 50MB fileSize: 50 * 1024 * 1024, // 50MB per file (separate concern from #613)
files: 10 // Max 10 files at once files: maxFilesPerUpload
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
@@ -1651,7 +1666,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
cb(new Error('Invalid file type')); cb(new Error('Invalid file type'));
} }
} }
}).array('photos', 10); }).array('photos', maxFilesPerUpload);
// Handle upload // Handle upload
upload(req, res, async (err) => { upload(req, res, async (err) => {
+20 -1
View File
@@ -18,7 +18,15 @@ router.get('/', async (req, res) => {
'event_default_require_password', 'event_default_require_password',
'event_default_feedback_enabled', 'event_default_feedback_enabled',
'gallery_show_filter_bar', 'gallery_show_filter_bar',
'event_phone_field_enabled' 'event_phone_field_enabled',
// #613 — guest upload UI needs to know the per-batch file
// count limit so it can render a real number in the
// "fileRequirements" hint (`{{limit}}` was showing literal)
// and short-circuit before posting too many files. The
// backend route also enforces it via getMaxFilesPerUpload,
// but a client-side guard saves a 4MB+ round-trip when the
// limit is small.
'general_max_files_per_upload'
]); ]);
}) })
.select('setting_key', 'setting_value'); .select('setting_key', 'setting_value');
@@ -145,6 +153,17 @@ router.get('/', async (req, res) => {
gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false, gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false,
// Upload settings (safe to expose - needed for client-side validation) // Upload settings (safe to expose - needed for client-side validation)
allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp', allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp',
// #613 — per-batch file count limit. The guest UserPhotoUpload modal
// reads this both to render the "{{limit}} files per upload" hint
// (showed `{{limit}}` literal before this) and to refuse a batch
// before posting it. Backend route enforces the same value via
// getMaxFilesPerUpload(), so the client-side check is purely a UX
// optimisation. Default mirrors uploadSettings.js
// DEFAULT_MAX_FILES_PER_UPLOAD so the UI shows a sensible number
// even on installs that have never explicitly set the value.
general_max_files_per_upload: Number.isFinite(Number(settingsObject.general_max_files_per_upload))
? Number(settingsObject.general_max_files_per_upload)
: 500,
// SEO meta tag flags (safe to expose - these are intended for crawlers) // SEO meta tag flags (safe to expose - these are intended for crawlers)
seo_meta_noindex: settingsObject.seo_meta_noindex === true, seo_meta_noindex: settingsObject.seo_meta_noindex === true,
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true, seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
@@ -32,6 +32,17 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const { data: publicSettings } = usePublicSettings(); 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( const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(publicSettings?.allowed_file_types), () => extensionsToMimeTypes(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types] [publicSettings?.allowed_file_types]
@@ -57,6 +68,20 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
return true; return true;
}); });
if (validFiles.length === 0) return; 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]); setFiles((prev) => [...prev, ...validFiles]);
}; };
@@ -200,7 +225,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{t('upload.clickToUpload')} {t('upload.clickToUpload')}
</p> </p>
<p className="text-xs text-muted-theme"> <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> </p>
<input <input
type="file" type="file"
@@ -70,6 +70,10 @@ export interface PublicSettings {
umami_share_url: string | null; umami_share_url: string | null;
// Upload settings // Upload settings
allowed_file_types?: string; 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 field requirements
event_require_customer_name?: boolean; event_require_customer_name?: boolean;
event_require_customer_email?: boolean; event_require_customer_email?: boolean;