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:
@@ -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();
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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