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:
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user