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 { 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();
+12 -2
View File
@@ -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'));
+2
View File
@@ -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,
+87 -1
View File
@@ -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
};
+15 -4
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 { 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<PhotoUploadProps> = ({ 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<HTMLInputElement>) => {
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<PhotoUploadProps> = ({ 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"
/>
@@ -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<UserPhotoUploadProps> = ({
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<HTMLInputElement>) => {
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<UserPhotoUploadProps> = ({
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}
/>
@@ -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;
+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(',');
}