From fe07a148f1d998c0be00377c1f8b4eca3908305c Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 1 Mar 2026 14:36:34 +0100 Subject: [PATCH] fix: respect allowed_file_types setting for upload validation (#203) The "Allowed File Types" admin setting was stored in the database but never actually read during upload validation. Both frontend and backend used hardcoded MIME type lists, causing video uploads (e.g. MP4) to be rejected even when explicitly added to the setting. Changes: - Add getAllowedMimeTypes() to uploadSettings service that reads the general_allowed_file_types DB setting and converts extensions to MIME types - Backend admin upload route now resolves allowed types from settings before multer processes files (via resolveAllowedTypes middleware) - Backend gallery upload route uses dynamic allowed types from settings - Expose allowed_file_types in public settings API for gallery clients - Frontend PhotoUpload and UserPhotoUpload components now derive allowed MIME types from settings instead of hardcoded image-only lists - Add shared fileTypes.ts utility for extension-to-MIME conversion Closes #203 --- backend/src/routes/adminPhotos.js | 56 +++++++----- backend/src/routes/gallery.js | 14 ++- backend/src/routes/publicSettings.js | 2 + backend/src/services/uploadSettings.js | 88 ++++++++++++++++++- frontend/src/components/admin/PhotoUpload.tsx | 19 +++- .../components/gallery/UserPhotoUpload.tsx | 28 ++++-- .../src/services/publicSettings.service.ts | 2 + frontend/src/utils/fileTypes.ts | 48 ++++++++++ 8 files changed, 221 insertions(+), 36 deletions(-) create mode 100644 frontend/src/utils/fileTypes.ts 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(','); +}