diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index a3c4d540..2a33f399 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -9,7 +9,7 @@ const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../s const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); -const { getMaxFilesPerUpload } = require('../services/uploadSettings'); +const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings'); const { processUploadedPhotos } = require('../services/photoProcessor'); const chunkedUpload = require('../services/chunkedUploadService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService'); @@ -47,47 +47,55 @@ const storage = multer.diskStorage({ } }); -const { validateFileType } = require('../utils/fileSecurityUtils'); +const { validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils'); +// Create a multer instance that uses dynamically resolved allowed MIME types. +// The allowed types are fetched from the database once per request (before multer +// processes files) and attached to req.allowedMimeTypes so that the fileFilter +// callback can read them synchronously. const upload = multer({ storage: storage, limits: { fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos 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 - parts: 10000, // Maximum number of parts (fields + files) - headerPairs: 2000 // Maximum number of header key-value pairs + parts: 10000, + headerPairs: 2000 }, fileFilter: (req, file, cb) => { - // Accept images and videos with proper validation - const allowedMimeTypes = [ - 'image/jpeg', 'image/png', 'image/webp', - 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo' - ]; + // req.allowedMimeTypes is populated by the middleware that runs before multer + const allowedMimeTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp']; if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { return cb(null, true); } else { - cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed')); + cb(new Error('Invalid file type. Check allowed file types in system settings.')); } }, - // Add abort on limit to stop processing when limits are exceeded abortOnLimit: true }); -const { createFileUploadValidator } = require('../utils/fileSecurityUtils'); +// Middleware to resolve allowed MIME types from settings before multer runs +const resolveAllowedTypes = async (req, res, next) => { + try { + req.allowedMimeTypes = await getAllowedMimeTypes(); + } catch (error) { + console.error('Failed to resolve allowed MIME types:', error); + req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; + } + next(); +}; -// Create content validator middleware -const validateUploadContent = createFileUploadValidator({ - allowedTypes: [ - 'image/jpeg', 'image/png', 'image/webp', - 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo' - ], - maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos - validateContent: true -}); +// Dynamic content validator middleware that reads allowed types from req +const validateUploadContent = async (req, res, next) => { + const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp']; + const validator = createFileUploadValidator({ + allowedTypes, + maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos + validateContent: true + }); + return validator(req, res, next); +}; // Request timeout middleware for uploads const uploadTimeout = (timeout = 300000) => { // 5 minutes default @@ -111,7 +119,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default // Upload photos for an event // Max file count is configurable via general settings -router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout +router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout let maxFilesPerUpload; try { maxFilesPerUpload = await getMaxFilesPerUpload(); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 37fd1932..b5af23b8 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -1169,6 +1169,17 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => { // Import multer and photo processing const multer = require('multer'); + const { getAllowedMimeTypes } = require('../services/uploadSettings'); + const { validateFileType } = require('../utils/fileSecurityUtils'); + + // Resolve allowed MIME types from settings + let allowedMimeTypes; + try { + allowedMimeTypes = await getAllowedMimeTypes(); + } catch { + allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; + } + const upload = multer({ dest: tempUploadDir, limits: { @@ -1176,8 +1187,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => { files: 10 // Max 10 files at once }, fileFilter: (req, file, cb) => { - const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; - if (allowedTypes.includes(file.mimetype)) { + if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { cb(null, true); } else { cb(new Error('Invalid file type')); diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 1de8a673..23600ce4 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -78,6 +78,8 @@ router.get('/', async (req, res) => { event_require_admin_email: settingsObject.event_require_admin_email !== false, event_require_event_date: settingsObject.event_require_event_date !== false, event_require_expiration: settingsObject.event_require_expiration !== false, + // Upload settings (safe to expose - needed for client-side validation) + allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp', // SEO meta tag flags (safe to expose - these are intended for crawlers) seo_meta_noindex: settingsObject.seo_meta_noindex === true, seo_meta_nofollow: settingsObject.seo_meta_nofollow === true, diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js index 2998504d..62f71b8f 100644 --- a/backend/src/services/uploadSettings.js +++ b/backend/src/services/uploadSettings.js @@ -7,6 +7,25 @@ const CACHE_TTL_MS = 60_000; let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD; let cacheExpiresAt = 0; +// Map of file extension to MIME type(s) +const EXTENSION_TO_MIME = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'webp': 'image/webp', + 'gif': 'image/gif', + 'mp4': 'video/mp4', + 'm4v': 'video/mp4', + 'webm': 'video/webm', + 'mov': 'video/quicktime', + 'avi': 'video/x-msvideo', +}; + +const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp'; + +let cachedAllowedTypes = null; +let allowedTypesCacheExpiresAt = 0; + const parseSettingValue = (setting) => { if (!setting || setting.setting_value == null) { return null; @@ -79,9 +98,76 @@ const clearMaxFilesPerUploadCache = () => { cacheExpiresAt = 0; }; +/** + * Convert a comma-separated list of file extensions into an array of MIME types. + * Unknown extensions are silently ignored. + */ +const extensionsToMimeTypes = (extString) => { + if (!extString || typeof extString !== 'string') { + return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES); + } + + const mimeSet = new Set(); + extString.split(',').forEach(ext => { + const cleaned = ext.trim().toLowerCase().replace(/^\./, ''); + const mime = EXTENSION_TO_MIME[cleaned]; + if (mime) { + mimeSet.add(mime); + } + }); + + if (mimeSet.size === 0) { + return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES); + } + + return Array.from(mimeSet); +}; + +/** + * Get the allowed MIME types for uploads from the database setting. + * Returns an array of MIME type strings, e.g. ['image/jpeg', 'image/png', 'video/mp4']. + */ +const getAllowedMimeTypes = async () => { + if (Date.now() < allowedTypesCacheExpiresAt && cachedAllowedTypes) { + return cachedAllowedTypes; + } + + try { + const setting = await db('app_settings') + .where({ setting_key: 'general_allowed_file_types' }) + .first(); + + let rawValue = setting?.setting_value; + if (typeof rawValue === 'string') { + try { rawValue = JSON.parse(rawValue); } catch { /* keep string */ } + } + + const mimeTypes = extensionsToMimeTypes(rawValue); + cachedAllowedTypes = mimeTypes; + allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS; + return mimeTypes; + } catch (error) { + console.error('Failed to read allowed file types setting:', error.message); + const fallback = extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES); + cachedAllowedTypes = fallback; + allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS; + return fallback; + } +}; + +const clearAllowedTypesCache = () => { + allowedTypesCacheExpiresAt = 0; + cachedAllowedTypes = null; +}; + module.exports = { getMaxFilesPerUpload, clearMaxFilesPerUploadCache, + getAllowedMimeTypes, + clearAllowedTypesCache, + extensionsToMimeTypes, + EXTENSION_TO_MIME, DEFAULT_MAX_FILES_PER_UPLOAD, - MAX_ALLOWED_FILES_PER_UPLOAD + MAX_ALLOWED_FILES_PER_UPLOAD, + DEFAULT_ALLOWED_FILE_TYPES }; diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index a0dd5d61..fd0789b1 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import { Upload, X, Image, Loader2 } from 'lucide-react'; import { Button } from '../common'; import { clsx } from 'clsx'; @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query'; import { categoriesService } from '../../services/categories.service'; import { settingsService } from '../../services/settings.service'; import { useTranslation } from 'react-i18next'; +import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes'; interface PhotoUploadProps { eventId: number; @@ -47,12 +48,22 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed))); }, [settings]); + const allowedMimeTypes = useMemo( + () => extensionsToMimeTypes(settings?.general_allowed_file_types), + [settings?.general_allowed_file_types] + ); + + const acceptString = useMemo( + () => extensionsToAcceptString(settings?.general_allowed_file_types), + [settings?.general_allowed_file_types] + ); + 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 => - ['image/jpeg', 'image/png', 'image/webp'].includes(file.type) + const imageFiles = files.filter(file => + allowedMimeTypes.includes(file.type) ); // Check total file count with existing files @@ -254,7 +265,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl ref={fileInputRef} type="file" multiple - accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo" + accept={acceptString} onChange={handleFileSelect} className="hidden" /> diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 29eb7f26..1a7f637c 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -1,9 +1,12 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Upload, X, CheckCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { Button } from '../common'; import { api } from '../../config/api'; +import { publicSettingsService } from '../../services/publicSettings.service'; +import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes'; interface UserPhotoUploadProps { eventId: number; @@ -23,13 +26,28 @@ export const UserPhotoUpload: React.FC = ({ const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({}); + const { data: publicSettings } = useQuery({ + queryKey: ['public-settings'], + queryFn: () => publicSettingsService.getPublicSettings(), + staleTime: 5 * 60 * 1000, + }); + + const allowedMimeTypes = useMemo( + () => extensionsToMimeTypes(publicSettings?.allowed_file_types), + [publicSettings?.allowed_file_types] + ); + + const acceptString = useMemo( + () => extensionsToAcceptString(publicSettings?.allowed_file_types), + [publicSettings?.allowed_file_types] + ); + const handleFileSelect = (e: React.ChangeEvent) => { const selectedFiles = Array.from(e.target.files || []); - + // Validate file types - const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; const validFiles = selectedFiles.filter(file => { - if (!allowedTypes.includes(file.type)) { + if (!allowedMimeTypes.includes(file.type)) { toast.error(`Invalid file type: ${file.name}`); return false; } @@ -143,7 +161,7 @@ export const UserPhotoUpload: React.FC = ({ type="file" className="hidden" multiple - accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo" + accept={acceptString} onChange={handleFileSelect} disabled={uploading} /> diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index 5e6835bb..2655ae0c 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -23,6 +23,8 @@ export interface PublicSettings { umami_url: string | null; umami_website_id: string | null; umami_share_url: string | null; + // Upload settings + allowed_file_types?: string; // Event field requirements event_require_customer_name?: boolean; event_require_customer_email?: boolean; diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts new file mode 100644 index 00000000..ba5514c9 --- /dev/null +++ b/frontend/src/utils/fileTypes.ts @@ -0,0 +1,48 @@ +/** + * Maps file extensions to MIME types for upload validation. + */ +const EXTENSION_TO_MIME: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + mp4: 'video/mp4', + m4v: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + avi: 'video/x-msvideo', +}; + +const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp'; + +/** + * Convert a comma-separated extension string (e.g. "jpg,png,mp4") to an + * array of unique MIME types. + */ +export function extensionsToMimeTypes(extString?: string | null): string[] { + const input = extString?.trim() || DEFAULT_ALLOWED; + const mimeSet = new Set(); + + input.split(',').forEach(ext => { + const cleaned = ext.trim().toLowerCase().replace(/^\./, ''); + const mime = EXTENSION_TO_MIME[cleaned]; + if (mime) { + mimeSet.add(mime); + } + }); + + if (mimeSet.size === 0) { + return extensionsToMimeTypes(DEFAULT_ALLOWED); + } + + return Array.from(mimeSet); +} + +/** + * Convert a comma-separated extension string to an HTML `accept` attribute + * value, e.g. "image/jpeg,image/png,video/mp4". + */ +export function extensionsToAcceptString(extString?: string | null): string { + return extensionsToMimeTypes(extString).join(','); +}