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');
}