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
This commit is contained in:
Paul Nothaft
2026-03-01 14:36:34 +01:00
parent 33483cf32d
commit fe07a148f1
8 changed files with 221 additions and 36 deletions
+32 -24
View File
@@ -9,7 +9,7 @@ const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../s
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings'); const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor'); const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService'); const chunkedUpload = require('../services/chunkedUploadService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService'); 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({ const upload = multer({
storage: storage, storage: storage,
limits: { limits: {
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
files: 2000, // Hard safety ceiling; actual limit enforced dynamically 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 fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
// Add part size limits to prevent incomplete uploads parts: 10000,
parts: 10000, // Maximum number of parts (fields + files) headerPairs: 2000
headerPairs: 2000 // Maximum number of header key-value pairs
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Accept images and videos with proper validation // req.allowedMimeTypes is populated by the middleware that runs before multer
const allowedMimeTypes = [ const allowedMimeTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
'image/jpeg', 'image/png', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true); return cb(null, true);
} else { } 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 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 // Dynamic content validator middleware that reads allowed types from req
const validateUploadContent = createFileUploadValidator({ const validateUploadContent = async (req, res, next) => {
allowedTypes: [ const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
'image/jpeg', 'image/png', 'image/webp', const validator = createFileUploadValidator({
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo' allowedTypes,
], maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos validateContent: true
validateContent: true });
}); return validator(req, res, next);
};
// Request timeout middleware for uploads // Request timeout middleware for uploads
const uploadTimeout = (timeout = 300000) => { // 5 minutes default const uploadTimeout = (timeout = 300000) => { // 5 minutes default
@@ -111,7 +119,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
// Upload photos for an event // Upload photos for an event
// Max file count is configurable via general settings // 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; let maxFilesPerUpload;
try { try {
maxFilesPerUpload = await getMaxFilesPerUpload(); maxFilesPerUpload = await getMaxFilesPerUpload();
+12 -2
View File
@@ -1169,6 +1169,17 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
// Import multer and photo processing // Import multer and photo processing
const multer = require('multer'); 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({ const upload = multer({
dest: tempUploadDir, dest: tempUploadDir,
limits: { limits: {
@@ -1176,8 +1187,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
files: 10 // Max 10 files at once files: 10 // Max 10 files at once
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
if (allowedTypes.includes(file.mimetype)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Invalid file type')); cb(new Error('Invalid file type'));
+2
View File
@@ -78,6 +78,8 @@ router.get('/', async (req, res) => {
event_require_admin_email: settingsObject.event_require_admin_email !== false, event_require_admin_email: settingsObject.event_require_admin_email !== false,
event_require_event_date: settingsObject.event_require_event_date !== false, event_require_event_date: settingsObject.event_require_event_date !== false,
event_require_expiration: settingsObject.event_require_expiration !== 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 tag flags (safe to expose - these are intended for crawlers)
seo_meta_noindex: settingsObject.seo_meta_noindex === true, seo_meta_noindex: settingsObject.seo_meta_noindex === true,
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true, seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
+87 -1
View File
@@ -7,6 +7,25 @@ const CACHE_TTL_MS = 60_000;
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD; let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
let cacheExpiresAt = 0; 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) => { const parseSettingValue = (setting) => {
if (!setting || setting.setting_value == null) { if (!setting || setting.setting_value == null) {
return null; return null;
@@ -79,9 +98,76 @@ const clearMaxFilesPerUploadCache = () => {
cacheExpiresAt = 0; 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 = { module.exports = {
getMaxFilesPerUpload, getMaxFilesPerUpload,
clearMaxFilesPerUploadCache, clearMaxFilesPerUploadCache,
getAllowedMimeTypes,
clearAllowedTypesCache,
extensionsToMimeTypes,
EXTENSION_TO_MIME,
DEFAULT_MAX_FILES_PER_UPLOAD, DEFAULT_MAX_FILES_PER_UPLOAD,
MAX_ALLOWED_FILES_PER_UPLOAD MAX_ALLOWED_FILES_PER_UPLOAD,
DEFAULT_ALLOWED_FILE_TYPES
}; };
+14 -3
View File
@@ -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 { Upload, X, Image, Loader2 } from 'lucide-react';
import { Button } from '../common'; import { Button } from '../common';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
@@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service'; import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
interface PhotoUploadProps { interface PhotoUploadProps {
eventId: number; eventId: number;
@@ -47,12 +48,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed))); return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
}, [settings]); }, [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 remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file => const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type) allowedMimeTypes.includes(file.type)
); );
// Check total file count with existing files // Check total file count with existing files
@@ -254,7 +265,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo" accept={acceptString}
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
/> />
@@ -1,9 +1,12 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { Upload, X, CheckCircle } from 'lucide-react'; import { Upload, X, CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Button } from '../common'; import { Button } from '../common';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { publicSettingsService } from '../../services/publicSettings.service';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
interface UserPhotoUploadProps { interface UserPhotoUploadProps {
eventId: number; eventId: number;
@@ -23,13 +26,28 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({}); 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<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []); const selectedFiles = Array.from(e.target.files || []);
// Validate file types // Validate file types
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
const validFiles = selectedFiles.filter(file => { const validFiles = selectedFiles.filter(file => {
if (!allowedTypes.includes(file.type)) { if (!allowedMimeTypes.includes(file.type)) {
toast.error(`Invalid file type: ${file.name}`); toast.error(`Invalid file type: ${file.name}`);
return false; return false;
} }
@@ -143,7 +161,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
type="file" type="file"
className="hidden" className="hidden"
multiple multiple
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo" accept={acceptString}
onChange={handleFileSelect} onChange={handleFileSelect}
disabled={uploading} disabled={uploading}
/> />
@@ -23,6 +23,8 @@ export interface PublicSettings {
umami_url: string | null; umami_url: string | null;
umami_website_id: string | null; umami_website_id: string | null;
umami_share_url: string | null; umami_share_url: string | null;
// Upload settings
allowed_file_types?: string;
// Event field requirements // Event field requirements
event_require_customer_name?: boolean; event_require_customer_name?: boolean;
event_require_customer_email?: boolean; event_require_customer_email?: boolean;
+48
View File
@@ -0,0 +1,48 @@
/**
* Maps file extensions to MIME types for upload validation.
*/
const EXTENSION_TO_MIME: Record<string, string> = {
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<string>();
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(',');
}