From 433fb9a989ed95d961b86b075b09703dbb8ed981 Mon Sep 17 00:00:00 2001 From: Dodothereal <129273127+Dodothereal@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:35:53 +0200 Subject: [PATCH 1/3] fix(uploads): show configured guest file types Assisted-by: Claude Code --- .../components/gallery/UserPhotoUpload.tsx | 16 +++++--- frontend/src/i18n/locales/de.json | 2 +- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/es.json | 2 +- frontend/src/i18n/locales/fr.json | 2 +- frontend/src/i18n/locales/nl.json | 2 +- frontend/src/i18n/locales/pt.json | 2 +- frontend/src/i18n/locales/ru.json | 2 +- frontend/src/i18n/locales/sl.json | 2 +- .../src/services/publicSettings.service.ts | 2 + .../src/utils/__tests__/fileTypes.test.ts | 37 +++++++++++++++++++ frontend/src/utils/fileTypes.ts | 35 ++++++++++-------- 12 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 frontend/src/utils/__tests__/fileTypes.test.ts diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 0f4ffef9..4d57f787 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -5,7 +5,7 @@ import { toast } from 'react-toastify'; import { Button } from '../common'; import { api } from '../../config/api'; import { usePublicSettings } from '../../hooks/usePublicSettings'; -import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes'; +import { extensionsToMimeTypes, extensionsToAcceptString, getSupportedExtensions } from '../../utils/fileTypes'; interface UserPhotoUploadProps { eventId: number; @@ -61,6 +61,11 @@ export const UserPhotoUpload: React.FC = ({ [publicSettings?.allowed_file_types] ); + const allowedExtensions = useMemo( + () => getSupportedExtensions(publicSettings?.allowed_file_types), + [publicSettings?.allowed_file_types] + ); + // Shared filter pipeline for both change and drag-and-drop (#504). const addFiles = (incoming: File[]) => { const validFiles = incoming.filter((file) => { @@ -233,10 +238,11 @@ export const UserPhotoUpload: React.FC = ({ {t('upload.clickToUpload')}

- {/* #613 — pass { limit } so `{{limit}}` interpolates - with the real number from settings instead of - rendering literally. */} - {t('upload.fileRequirements', { limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })} + {t('upload.fileRequirements', { + types: allowedExtensions.map(extension => extension.toUpperCase()).join(', '), + limit: maxFilesPerUpload, + sizeLimit: maxFileSizeMb, + })}

{ + it('keeps configured supported extensions for the upload hint', () => { + expect(getSupportedExtensions('jpg, mov, mp4, .webp')).toEqual([ + 'jpg', + 'mov', + 'mp4', + 'webp', + ]); + }); + + it('uses the same configured types for validation and file selection', () => { + expect(extensionsToMimeTypes('jpg,jpeg,mov,mp4')).toEqual([ + 'image/jpeg', + 'video/quicktime', + 'video/mp4', + ]); + expect(extensionsToAcceptString('jpg,jpeg,mov,mp4')).toBe( + 'image/jpeg,video/quicktime,video/mp4' + ); + }); + + it('falls back to the default formats when no configured types are supported', () => { + expect(getSupportedExtensions('dng,unknown')).toEqual([ + 'jpg', + 'jpeg', + 'png', + 'webp', + ]); + }); +}); diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts index ba5514c9..46091dd8 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -16,27 +16,30 @@ const EXTENSION_TO_MIME: Record = { const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp'; +/** + * Return the configured supported extensions, excluding values the upload + * pipeline cannot validate. Falls back to the default list when none match. + */ +export function getSupportedExtensions(extString?: string | null): string[] { + const input = extString?.trim() || DEFAULT_ALLOWED; + const extensions = input + .split(',') + .map(ext => ext.trim().toLowerCase().replace(/^\./, '')) + .filter(ext => Boolean(EXTENSION_TO_MIME[ext])); + + return extensions.length > 0 + ? Array.from(new Set(extensions)) + : getSupportedExtensions(DEFAULT_ALLOWED); +} + /** * 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); + return Array.from(new Set( + getSupportedExtensions(extString).map(extension => EXTENSION_TO_MIME[extension]) + )); } /** From f4b685a5abef8071f49540648bb3f0ae033b0c58 Mon Sep 17 00:00:00 2001 From: Dodothereal <129273127+Dodothereal@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:50:32 +0200 Subject: [PATCH 2/3] fix(uploads): allow configured raw formats Assisted-by: Claude Code --- .../services/uploadSettingsFileTypes.test.js | 41 +++++++++++++++++++ backend/src/services/uploadSettings.js | 3 ++ backend/src/utils/fileSecurityUtils.js | 16 +++++++- .../src/utils/__tests__/fileTypes.test.ts | 16 +++++--- frontend/src/utils/fileTypes.ts | 3 ++ 5 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 backend/__tests__/services/uploadSettingsFileTypes.test.js diff --git a/backend/__tests__/services/uploadSettingsFileTypes.test.js b/backend/__tests__/services/uploadSettingsFileTypes.test.js new file mode 100644 index 00000000..e32a674f --- /dev/null +++ b/backend/__tests__/services/uploadSettingsFileTypes.test.js @@ -0,0 +1,41 @@ +const fs = require('fs'); +const path = require('path'); + +const { + EXTENSION_TO_MIME, + extensionsToMimeTypes, +} = require('../../src/services/uploadSettings'); +const { validateFileType } = require('../../src/utils/fileSecurityUtils'); + +const RAW_AND_HEIF_TYPES = { + dng: 'image/x-adobe-dng', + heic: 'image/heic', + heif: 'image/heif', +}; + +function getFrontendExtensionMap() { + const source = fs.readFileSync( + path.join(__dirname, '../../../frontend/src/utils/fileTypes.ts'), + 'utf8' + ); + const match = source.match(/const EXTENSION_TO_MIME[^=]*= \{([\s\S]*?)\n\};/); + if (!match) throw new Error('Could not find frontend EXTENSION_TO_MIME'); + + return Object.fromEntries( + Array.from(match[1].matchAll(/^(\s*)(\w+): '([^']+)',?$/gm), ([, , extension, mime]) => [extension, mime]) + ); +} + +describe('configured upload file types', () => { + test('supports configured DNG, HEIC, and HEIF uploads', () => { + expect(extensionsToMimeTypes('dng,heic,heif')).toEqual(Object.values(RAW_AND_HEIF_TYPES)); + + for (const [extension, mimeType] of Object.entries(RAW_AND_HEIF_TYPES)) { + expect(validateFileType(`image.${extension}`, mimeType, [mimeType])).toBe(true); + } + }); + + test('uses the same extension-to-MIME map as the frontend', () => { + expect(getFrontendExtensionMap()).toEqual(EXTENSION_TO_MIME); + }); +}); diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js index 06319cca..623a9966 100644 --- a/backend/src/services/uploadSettings.js +++ b/backend/src/services/uploadSettings.js @@ -24,6 +24,9 @@ const EXTENSION_TO_MIME = { 'png': 'image/png', 'webp': 'image/webp', 'gif': 'image/gif', + 'dng': 'image/x-adobe-dng', + 'heic': 'image/heic', + 'heif': 'image/heif', 'mp4': 'video/mp4', 'm4v': 'video/mp4', 'webm': 'video/webm', diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index 39e59b70..afe4f095 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -75,6 +75,20 @@ const ALLOWED_IMAGE_TYPES = { { offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] } // GIF89a ] }, + // DNG and HEIF-family files use TIFF/ISO Base Media File Format containers, + // so their extension and declared MIME type are validated together here. + 'image/x-adobe-dng': { + extensions: ['.dng'], + magicNumbers: null + }, + 'image/heic': { + extensions: ['.heic'], + magicNumbers: null + }, + 'image/heif': { + extensions: ['.heif'], + magicNumbers: null + }, 'image/svg+xml': { extensions: ['.svg'], // SVG files are XML-based text files, so we skip magic number validation @@ -191,7 +205,7 @@ function getSafeFilename(originalFilename) { const ext = path.extname(originalFilename).toLowerCase(); // Validate extension - including both image and video extensions - const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi']; + const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.dng', '.heic', '.heif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi']; if (!validExtensions.includes(ext)) { throw new Error('Invalid file extension'); } diff --git a/frontend/src/utils/__tests__/fileTypes.test.ts b/frontend/src/utils/__tests__/fileTypes.test.ts index e54ff4f1..b2ca856e 100644 --- a/frontend/src/utils/__tests__/fileTypes.test.ts +++ b/frontend/src/utils/__tests__/fileTypes.test.ts @@ -7,8 +7,11 @@ import { describe('file type settings', () => { it('keeps configured supported extensions for the upload hint', () => { - expect(getSupportedExtensions('jpg, mov, mp4, .webp')).toEqual([ + expect(getSupportedExtensions('jpg, dng, heic, heif, mov, mp4, .webp')).toEqual([ 'jpg', + 'dng', + 'heic', + 'heif', 'mov', 'mp4', 'webp', @@ -16,18 +19,21 @@ describe('file type settings', () => { }); it('uses the same configured types for validation and file selection', () => { - expect(extensionsToMimeTypes('jpg,jpeg,mov,mp4')).toEqual([ + expect(extensionsToMimeTypes('jpg,jpeg,dng,heic,heif,mov,mp4')).toEqual([ 'image/jpeg', + 'image/x-adobe-dng', + 'image/heic', + 'image/heif', 'video/quicktime', 'video/mp4', ]); - expect(extensionsToAcceptString('jpg,jpeg,mov,mp4')).toBe( - 'image/jpeg,video/quicktime,video/mp4' + expect(extensionsToAcceptString('dng,heic,heif')).toBe( + 'image/x-adobe-dng,image/heic,image/heif' ); }); it('falls back to the default formats when no configured types are supported', () => { - expect(getSupportedExtensions('dng,unknown')).toEqual([ + expect(getSupportedExtensions('unknown')).toEqual([ 'jpg', 'jpeg', 'png', diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts index 46091dd8..cd2ff22f 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -7,6 +7,9 @@ const EXTENSION_TO_MIME: Record = { png: 'image/png', webp: 'image/webp', gif: 'image/gif', + dng: 'image/x-adobe-dng', + heic: 'image/heic', + heif: 'image/heif', mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', From c8eb33463784e895d8eae1fd0b4640e56b617d9d Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:43:26 +0200 Subject: [PATCH 3/3] test(uploads): harden frontend map parser, drop dead getSafeFilename edit (codex review of #834) - getFrontendExtensionMap now tolerates quoted keys and trailing comments and throws on any other unparsable map line, so future syntax drift fails loudly instead of silently dropping entries from the comparison. - Revert the .dng/.heic/.heif addition to getSafeFilename: the helper has no callers, so the edit was dead code. Live validation paths already cover these formats. --- .../services/uploadSettingsFileTypes.test.js | 16 +++++++++++++--- backend/src/utils/fileSecurityUtils.js | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/services/uploadSettingsFileTypes.test.js b/backend/__tests__/services/uploadSettingsFileTypes.test.js index e32a674f..13b38736 100644 --- a/backend/__tests__/services/uploadSettingsFileTypes.test.js +++ b/backend/__tests__/services/uploadSettingsFileTypes.test.js @@ -21,9 +21,19 @@ function getFrontendExtensionMap() { const match = source.match(/const EXTENSION_TO_MIME[^=]*= \{([\s\S]*?)\n\};/); if (!match) throw new Error('Could not find frontend EXTENSION_TO_MIME'); - return Object.fromEntries( - Array.from(match[1].matchAll(/^(\s*)(\w+): '([^']+)',?$/gm), ([, , extension, mime]) => [extension, mime]) - ); + // Parse `key: 'mime',` entries — quoted keys and trailing `//` comments are + // tolerated; any other non-blank, non-comment line inside the map is a parse + // failure, so a syntax the parser can't read fails loudly instead of silently + // dropping the entry from the comparison. + const entries = []; + for (const line of match[1].split('\n')) { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('//')) continue; + const entry = trimmed.match(/^'?(\w+)'?\s*:\s*'([^']+)'\s*,?\s*(?:\/\/.*)?$/); + if (!entry) throw new Error(`Unparsable EXTENSION_TO_MIME line in frontend fileTypes.ts: "${trimmed}"`); + entries.push([entry[1], entry[2]]); + } + return Object.fromEntries(entries); } describe('configured upload file types', () => { diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index 599a3425..5eec0f0f 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -224,7 +224,7 @@ function getSafeFilename(originalFilename) { const ext = path.extname(originalFilename).toLowerCase(); // Validate extension - including both image and video extensions - const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.dng', '.heic', '.heif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi']; + const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi']; if (!validExtensions.includes(ext)) { throw new Error('Invalid file extension'); }