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:
@@ -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;
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
Reference in New Issue
Block a user