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
- {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 = () => { -+ {t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })} +
+