Merge remote-tracking branch 'origin/main' into feat/guest-upload-dng-raw

# Conflicts:
#	backend/src/services/uploadSettings.js
#	backend/src/utils/fileSecurityUtils.js
#	frontend/src/utils/fileTypes.ts
This commit is contained in:
Paul Nothaft
2026-07-18 20:52:08 +02:00
23 changed files with 197 additions and 21 deletions
+11 -2
View File
@@ -8,7 +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';
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps {
@@ -118,6 +118,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
[settings?.general_allowed_file_types]
);
const formatsLabel = useMemo(
() => extensionsToLabel(settings?.general_allowed_file_types),
[settings?.general_allowed_file_types]
);
const maxFileSizeMb = Number.isFinite(Number(settings?.general_max_file_size_mb))
? Number(settings?.general_max_file_size_mb)
: 50;
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const [isDragOver, setIsDragOver] = useState(false);
@@ -511,7 +520,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
{t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
</p>
<p
className={clsx(
@@ -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, extensionsToLabel } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
eventId: number;
@@ -61,6 +61,13 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
[publicSettings?.allowed_file_types]
);
// #821 — the requirements hint used to hardcode "JPEG, PNG or WebP"; render
// the actually-configured formats so it never contradicts what's accepted.
const formatsLabel = useMemo(
() => extensionsToLabel(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
);
// Shared filter pipeline for both <input> change and drag-and-drop (#504).
const addFiles = (incoming: File[]) => {
const validFiles = incoming.filter((file) => {
@@ -236,7 +243,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* #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', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
</p>
<input
type="file"
+1 -1
View File
@@ -159,7 +159,7 @@
"noCategory": "Keine Kategorie",
"eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. {{sizeLimit}}MB pro Datei, {{limit}} Dateien pro Upload)",
"fileRequirements": "{{formats}} (max. {{sizeLimit}}MB pro Datei, {{limit}} Dateien pro Upload)",
"selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...",
"transferring": "Übertragung",
+1 -1
View File
@@ -159,7 +159,7 @@
"noCategory": "No category",
"eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max {{sizeLimit}}MB per file, {{limit}} files per upload)",
"fileRequirements": "{{formats}} (max {{sizeLimit}}MB per file, {{limit}} files per upload)",
"selectedFiles": "Selected files",
"uploading": "Uploading...",
"transferring": "Transferring",
+1 -1
View File
@@ -126,7 +126,7 @@
"noCategory": "Sin categoría",
"eventSpecific": "(Específico del evento)",
"clickToUpload": "Haz clic para subir o arrastra y suelta",
"fileRequirements": "JPEG, PNG o WebP (máx. {{sizeLimit}}MB por archivo, {{limit}} archivos por subida)",
"fileRequirements": "{{formats}} (máx. {{sizeLimit}}MB por archivo, {{limit}} archivos por subida)",
"fileRequirementsMedia": "Imágenes JPEG, PNG o WebP y videos MP4/MOV/WEBM (máx. 50MB por archivo, {{limit}} archivos por subida)",
"unsupportedFiles": "Algunos archivos se omitieron porque el formato no es compatible (usa JPEG/PNG/WebP/MP4/MOV/WEBM).",
"selectedFiles": "Archivos seleccionados",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Aucune catégorie",
"eventSpecific": "(Spécifique à l'événement)",
"clickToUpload": "Cliquez pour téléverser ou glissez-déposez",
"fileRequirements": "JPEG, PNG ou WebP (max {{sizeLimit}} Mo par fichier, {{limit}} fichiers par téléversement)",
"fileRequirements": "{{formats}} (max {{sizeLimit}} Mo par fichier, {{limit}} fichiers par téléversement)",
"selectedFiles": "Fichiers sélectionnés",
"uploading": "Téléversement en cours...",
"transferring": "Transfert en cours",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Geen categorie",
"eventSpecific": "(Evenement-specifiek)",
"clickToUpload": "Klik om te uploaden of sleep bestanden hierheen",
"fileRequirements": "JPEG, PNG of WebP (max. {{sizeLimit}} MB per bestand, {{limit}} bestanden per upload)",
"fileRequirements": "{{formats}} (max. {{sizeLimit}} MB per bestand, {{limit}} bestanden per upload)",
"selectedFiles": "Geselecteerde bestanden",
"uploading": "Uploaden...",
"uploadComplete": "Upload voltooid!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Sem categoria",
"eventSpecific": "(Específico do evento)",
"clickToUpload": "Clique para enviar ou arraste e solte",
"fileRequirements": "JPEG, PNG ou WebP (máx. {{sizeLimit}}MB por arquivo, {{limit}} arquivos por envio)",
"fileRequirements": "{{formats}} (máx. {{sizeLimit}}MB por arquivo, {{limit}} arquivos por envio)",
"selectedFiles": "Arquivos selecionados",
"uploading": "Enviando...",
"uploadComplete": "Envio concluído!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Без категории",
"eventSpecific": "(Для конкретного события)",
"clickToUpload": "Нажмите для загрузки или перетащите файлы",
"fileRequirements": "JPEG, PNG или WebP (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
"fileRequirements": "{{formats}} (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
"selectedFiles": "Выбранные файлы",
"uploading": "Загрузка...",
"uploadComplete": "Загрузка завершена!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Brez kategorije",
"eventSpecific": "(specifično za dogodek)",
"clickToUpload": "Kliknite za nalaganje ali povlecite in spustite",
"fileRequirements": "JPEG, PNG ali WebP (največ {{sizeLimit}} MB na datoteko, {{limit}} datotek na nalaganje)",
"fileRequirements": "{{formats}} (največ {{sizeLimit}} MB na datoteko, {{limit}} datotek na nalaganje)",
"selectedFiles": "Izbrane datoteke",
"uploading": "Nalaganje...",
"transferring": "Prenašanje",
@@ -82,6 +82,9 @@ export interface PublicSettings {
// modal can render the real number in `upload.fileRequirements` and refuse
// oversized batches client-side. Backend enforces the same value too.
general_max_files_per_upload?: number;
// #613 follow-up — per-file size limit (MB), surfaced so the guest upload
// modal shows the real limit and guards client-side. Backend enforces it too.
general_max_file_size_mb?: number;
// Event field requirements
event_require_customer_name?: boolean;
event_require_customer_email?: boolean;
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../fileTypes';
describe('fileTypes', () => {
describe('extensionsToMimeTypes', () => {
it('maps known extensions to MIME types', () => {
expect(extensionsToMimeTypes('jpg,png,mov')).toEqual(['image/jpeg', 'image/png', 'video/quicktime']);
});
it('supports HEIC/HEIF (#821)', () => {
expect(extensionsToMimeTypes('heic,heif')).toEqual(['image/heic', 'image/heif']);
});
it('supports DNG (#821)', () => {
expect(extensionsToMimeTypes('dng')).toEqual(['image/x-adobe-dng']);
});
it('drops unknown extensions and falls back to default when nothing maps', () => {
expect(extensionsToMimeTypes('abc,xyz')).toEqual(['image/jpeg', 'image/png', 'image/webp']);
});
});
describe('extensionsToLabel', () => {
it('renders a de-duplicated, upper-cased list of the configured formats', () => {
expect(extensionsToLabel('jpg,jpeg,png,webp,mov')).toBe('JPG, JPEG, PNG, WEBP, MOV');
});
it('only lists supported extensions (drops unknowns like xyz)', () => {
expect(extensionsToLabel('jpg,png,xyz')).toBe('JPG, PNG');
});
it('falls back to the default set when empty', () => {
expect(extensionsToLabel('')).toBe('JPG, JPEG, PNG, WEBP');
expect(extensionsToLabel(null)).toBe('JPG, JPEG, PNG, WEBP');
});
});
describe('extensionsToAcceptString', () => {
it('joins MIME types for the input accept attribute', () => {
expect(extensionsToAcceptString('jpg,heic')).toBe('image/jpeg,image/heic');
});
});
});
+24
View File
@@ -12,6 +12,9 @@ const EXTENSION_TO_MIME: Record<string, string> = {
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
// HEIC/HEIF (iPhone) — kept in sync with the backend EXTENSION_TO_MIME.
heic: 'image/heic',
heif: 'image/heif',
// Camera RAW / Apple ProRAW — backend extracts the embedded JPEG preview.
dng: 'image/x-adobe-dng',
};
@@ -48,3 +51,24 @@ export function extensionsToMimeTypes(extString?: string | null): string[] {
export function extensionsToAcceptString(extString?: string | null): string {
return extensionsToMimeTypes(extString).join(',');
}
/**
* Human-readable, de-duplicated list of the configured extensions for the
* upload requirements hint, e.g. "JPG, PNG, WEBP, MOV". Only extensions the
* app actually supports (present in EXTENSION_TO_MIME) are shown, so the hint
* never advertises a format the backend would reject.
*/
export function extensionsToLabel(extString?: string | null): string {
const input = extString?.trim() || DEFAULT_ALLOWED;
const seen = new Set<string>();
const labels: string[] = [];
input.split(',').forEach(ext => {
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
if (cleaned && EXTENSION_TO_MIME[cleaned] && !seen.has(cleaned)) {
seen.add(cleaned);
labels.push(cleaned.toUpperCase());
}
});
if (labels.length === 0) return extensionsToLabel(DEFAULT_ALLOWED);
return labels.join(', ');
}