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 })} +

+