From 8f297e25c4a78069da9e809f9bee790050fa817a Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Tue, 14 Oct 2025 16:27:44 +0200
Subject: [PATCH] Make photo upload limit configurable via admin settings (#40)
---
.../045_add_max_files_per_upload_setting.js | 48 ++++++++++
backend/src/routes/adminPhotos.js | 21 +++--
backend/src/routes/adminSettings.js | 19 ++++
backend/src/services/uploadSettings.js | 87 +++++++++++++++++++
frontend/src/components/admin/PhotoUpload.tsx | 59 +++++++++++--
frontend/src/i18n/locales/de.json | 12 ++-
frontend/src/i18n/locales/en.json | 12 ++-
frontend/src/pages/admin/SettingsPage.tsx | 31 ++++++-
8 files changed, 266 insertions(+), 23 deletions(-)
create mode 100644 backend/migrations/core/045_add_max_files_per_upload_setting.js
create mode 100644 backend/src/services/uploadSettings.js
diff --git a/backend/migrations/core/045_add_max_files_per_upload_setting.js b/backend/migrations/core/045_add_max_files_per_upload_setting.js
new file mode 100644
index 0000000..f0d64c7
--- /dev/null
+++ b/backend/migrations/core/045_add_max_files_per_upload_setting.js
@@ -0,0 +1,48 @@
+const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
+
+exports.up = async function up(knex) {
+ const settingKey = 'general_max_files_per_upload';
+
+ const existing = await knex('app_settings')
+ .where({ setting_key: settingKey })
+ .first();
+
+ if (existing) {
+ // Normalize existing value into allowed bounds
+ let parsedValue;
+ try {
+ parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
+ } catch {
+ parsedValue = existing.setting_value;
+ }
+
+ const numeric = Number(parsedValue);
+ let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
+ if (Number.isFinite(numeric) && numeric >= 1) {
+ normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
+ }
+
+ if (normalized !== numeric) {
+ await knex('app_settings')
+ .where({ setting_key: settingKey })
+ .update({
+ setting_value: JSON.stringify(normalized),
+ updated_at: new Date()
+ });
+ }
+ return;
+ }
+
+ await knex('app_settings').insert({
+ setting_key: settingKey,
+ setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
+ setting_type: 'general',
+ updated_at: new Date()
+ });
+};
+
+exports.down = async function down(knex) {
+ await knex('app_settings')
+ .where({ setting_key: 'general_max_files_per_upload' })
+ .del();
+};
diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js
index e015467..57fe885 100644
--- a/backend/src/routes/adminPhotos.js
+++ b/backend/src/routes/adminPhotos.js
@@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
+const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const router = express.Router();
// Get storage path from environment or default
@@ -48,7 +49,7 @@ const upload = multer({
storage: storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit per file
- files: 500, // Maximum 500 files
+ files: 2000, // Hard safety ceiling; actual limit enforced dynamically
// Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
// Add part size limits to prevent incomplete uploads
@@ -99,17 +100,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
};
// Upload photos for an event
-// Increased limit to 500 files, but recommend chunked uploads for better performance
-router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
- upload.array('photos', 500)(req, res, (err) => {
+// Max file count is configurable via general settings
+router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
+ let maxFilesPerUpload;
+ try {
+ maxFilesPerUpload = await getMaxFilesPerUpload();
+ } catch (error) {
+ console.error('Failed to resolve max files per upload:', error);
+ return res.status(500).json({ error: 'Unable to determine upload limits' });
+ }
+
+ upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
}
- if (err.code === 'LIMIT_FILE_COUNT') {
- return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
+ if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
+ return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js
index 7c3a883..8905bc8 100644
--- a/backend/src/routes/adminSettings.js
+++ b/backend/src/routes/adminSettings.js
@@ -19,6 +19,7 @@ const {
} = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const router = express.Router();
+const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -472,9 +473,24 @@ router.put('/theme', adminAuth, async (req, res) => {
router.put('/general', adminAuth, async (req, res) => {
try {
const settings = { ...req.body };
+ let uploadLimitTouched = false;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
+ if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
+ uploadLimitTouched = true;
+ const rawValue = Number(settings.general_max_files_per_upload);
+ const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
+
+ if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
+ return res.status(400).json({
+ error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
+ });
+ }
+
+ settings.general_max_files_per_upload = normalizedValue;
+ }
+
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -529,6 +545,9 @@ router.put('/general', adminAuth, async (req, res) => {
if (publicSiteKeysTouched) {
clearPublicSiteCache();
}
+ if (uploadLimitTouched) {
+ clearMaxFilesPerUploadCache();
+ }
// Log activity
await db('activity_logs').insert({
diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js
new file mode 100644
index 0000000..2998504
--- /dev/null
+++ b/backend/src/services/uploadSettings.js
@@ -0,0 +1,87 @@
+const { db } = require('../database/db');
+
+const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
+const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
+const CACHE_TTL_MS = 60_000;
+
+let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
+let cacheExpiresAt = 0;
+
+const parseSettingValue = (setting) => {
+ if (!setting || setting.setting_value == null) {
+ return null;
+ }
+
+ let rawValue = setting.setting_value;
+
+ if (typeof rawValue === 'string') {
+ try {
+ rawValue = JSON.parse(rawValue);
+ } catch {
+ // keep original string
+ }
+ }
+
+ if (typeof rawValue === 'string') {
+ const trimmed = rawValue.trim();
+ if (trimmed === '') {
+ return null;
+ }
+ const parsed = Number(trimmed);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+
+ if (typeof rawValue === 'number') {
+ return rawValue;
+ }
+
+ return null;
+};
+
+const normalizeLimit = (value) => {
+ if (!Number.isFinite(value)) {
+ return DEFAULT_MAX_FILES_PER_UPLOAD;
+ }
+
+ const intValue = Math.floor(value);
+ if (intValue < 1) {
+ return DEFAULT_MAX_FILES_PER_UPLOAD;
+ }
+ if (intValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
+ return MAX_ALLOWED_FILES_PER_UPLOAD;
+ }
+ return intValue;
+};
+
+const getMaxFilesPerUpload = async () => {
+ if (Date.now() < cacheExpiresAt) {
+ return cachedValue;
+ }
+
+ try {
+ const setting = await db('app_settings')
+ .where({ setting_key: 'general_max_files_per_upload' })
+ .first();
+
+ const parsedValue = normalizeLimit(parseSettingValue(setting));
+ cachedValue = parsedValue;
+ cacheExpiresAt = Date.now() + CACHE_TTL_MS;
+ return parsedValue;
+ } catch (error) {
+ console.error('Failed to read max files per upload setting:', error.message);
+ cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
+ cacheExpiresAt = Date.now() + CACHE_TTL_MS;
+ return DEFAULT_MAX_FILES_PER_UPLOAD;
+ }
+};
+
+const clearMaxFilesPerUploadCache = () => {
+ cacheExpiresAt = 0;
+};
+
+module.exports = {
+ getMaxFilesPerUpload,
+ clearMaxFilesPerUploadCache,
+ DEFAULT_MAX_FILES_PER_UPLOAD,
+ MAX_ALLOWED_FILES_PER_UPLOAD
+};
diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx
index 743422a..154c6c0 100644
--- a/frontend/src/components/admin/PhotoUpload.tsx
+++ b/frontend/src/components/admin/PhotoUpload.tsx
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
+import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
interface PhotoUploadProps {
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
onUploadComplete?: () => void;
}
+const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
+const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
+
export const PhotoUpload: React.FC = ({ eventId, onUploadComplete }) => {
const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl
queryFn: () => categoriesService.getEventCategories(eventId),
});
+ const { data: settings } = useQuery({
+ queryKey: ['admin-settings'],
+ queryFn: () => settingsService.getAllSettings(),
+ });
+
+ const maxFilesPerUpload = React.useMemo(() => {
+ const rawValue = settings?.general_max_files_per_upload;
+ const parsed = Number(rawValue);
+ if (!Number.isFinite(parsed)) {
+ return DEFAULT_MAX_FILES_PER_UPLOAD;
+ }
+ return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
+ }, [settings]);
+
+ const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
+
const handleFileSelect = (e: React.ChangeEvent) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
- if (totalFiles > 500) {
- const allowedNewFiles = 500 - selectedFiles.length;
+ if (totalFiles > maxFilesPerUpload) {
+ const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) {
- toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
+ toast.error(
+ t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
+ `Maximum ${maxFilesPerUpload} files allowed`
+ );
return;
}
- toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
+ toast.warning(
+ t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
+ `Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
+ );
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl
if (selectedFiles.length === 0) return;
// Validate file count
- if (selectedFiles.length > 500) {
- toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
+ if (selectedFiles.length > maxFilesPerUpload) {
+ toast.error(
+ t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
+ `Maximum ${maxFilesPerUpload} files can be uploaded at once`
+ );
return;
}
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl
setUploadProgress(0);
// For large uploads, chunk the files to prevent memory issues
- const CHUNK_SIZE = 50; // Upload 50 files at a time
+ const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
const chunks = [];
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
@@ -187,7 +216,21 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl
{t('upload.clickToUpload')}
- {t('upload.fileRequirements')}
+ {t('upload.fileRequirements', { limit: maxFilesPerUpload })}
+
+
+ {remainingSlots === 0
+ ? t('upload.limitReached', { limit: maxFilesPerUpload })
+ : t('upload.limitInfo', {
+ selected: selectedFiles.length,
+ limit: maxFilesPerUpload,
+ remaining: remainingSlots,
+ })}
{
if (value === undefined || value === null) {
@@ -93,6 +94,7 @@ export const SettingsPage: React.FC = () => {
site_url: '',
default_expiration_days: 30,
max_file_size_mb: 50,
+ max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
enable_watermark: false,
enable_analytics: true,
@@ -145,6 +147,10 @@ export const SettingsPage: React.FC = () => {
site_url: settings.general_site_url || '',
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
+ max_files_per_upload: Math.min(
+ MAX_FILES_PER_UPLOAD_LIMIT,
+ Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
+ ),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
enable_watermark: toBoolean(settings.general_enable_watermark, false),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
@@ -645,7 +651,7 @@ export const SettingsPage: React.FC = () => {
-
+
+
+
+
{
+ const parsed = parseInt(e.target.value, 10);
+ setGeneralSettings(prev => ({
+ ...prev,
+ max_files_per_upload: Number.isFinite(parsed)
+ ? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
+ : prev.max_files_per_upload
+ }));
+ }}
+ min="1"
+ max={MAX_FILES_PER_UPLOAD_LIMIT}
+ />
+
+ {t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
+
+