diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index adcdee5a..07fce2fe 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -38,6 +38,24 @@ const { } = require('../services/downloadFilenameService'); const { buildContentDisposition } = require('../utils/filenameSanitizer'); const { getStorage } = require('../services/storage'); + +// Formats whose ORIGINAL bytes a browser can't render in an (HEIC/HEIF, +// camera RAW/DNG). For these the lightbox must be served the generated JPEG +// preview instead of `url` (the original) — otherwise it shows a broken image. +// So we force `preview_url` for them regardless of the lightbox_preview_enabled +// toggle. Detection is by MIME first, extension as a fallback (browsers report +// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders +// still depends on the backend being able to decode the source (HEVC-in-HEIC on +// the prod image; exiftool for DNG) — see #821. +const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']); +const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']); +function originalNeedsPreview(photo) { + const mime = (photo.mime_type || '').toLowerCase(); + if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true; + const name = photo.original_filename || photo.filename || ''; + const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; + return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext); +} const { setGalleryAuthCookies } = require('../utils/tokenUtils'); // Read globals from app_settings (the real table) — settingsService.getSetting // queries a non-existent `settings` table and throws. @@ -726,7 +744,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) // installs that haven't opted in keep loading the original // (current behaviour). Skipped for videos since they don't // get a preview tier; lightbox will use the original .url. - preview_url: lightboxPreviewEnabled + preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo)) && photo.media_type !== 'video' && (!photo.mime_type || !photo.mime_type.startsWith('video/')) ? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}` diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js index 06319cca..e102b7c8 100644 --- a/backend/src/services/uploadSettings.js +++ b/backend/src/services/uploadSettings.js @@ -29,6 +29,11 @@ const EXTENSION_TO_MIME = { 'webm': 'video/webm', 'mov': 'video/quicktime', 'avi': 'video/x-msvideo', + // HEIC/HEIF (iPhone). Sharp's bundled libvips decodes `heif` input, so + // thumbnails generate fine. (iOS Safari usually transcodes to JPEG at file + // selection, but a genuine .heic upload is handled when it does arrive.) + 'heic': 'image/heic', + 'heif': 'image/heif', }; const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp'; diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index 39e59b70..28155b33 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -79,6 +79,22 @@ const ALLOWED_IMAGE_TYPES = { extensions: ['.svg'], // SVG files are XML-based text files, so we skip magic number validation magicNumbers: null + }, + // HEIC/HEIF (iPhone). ISO-BMFF container: bytes 4-7 are the "ftyp" box marker, + // present in every HEIF/HEIC file (single entry — the magic check is `.every`, + // so alternatives can't be listed as separate entries). Sharp's libvips + // decodes these; extension + MIME are already gated by validateFileType. + 'image/heic': { + extensions: ['.heic'], + magicNumbers: [ + { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp" + ] + }, + 'image/heif': { + extensions: ['.heif'], + magicNumbers: [ + { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp" + ] } }; diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index 7d3f0ea1..b3a7d0a1 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -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 = ({ 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 = ({ eventId, onUploadCompl {t('upload.clickToUpload')}

- {t('upload.fileRequirements', { limit: maxFilesPerUpload })} + {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}

= ({ [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 change and drag-and-drop (#504). const addFiles = (incoming: File[]) => { const validFiles = incoming.filter((file) => { @@ -236,7 +243,7 @@ export const UserPhotoUpload: React.FC = ({ {/* #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 })}

{ + 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('drops unknown extensions and falls back to default when nothing maps', () => { + expect(extensionsToMimeTypes('dng,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 dng)', () => { + expect(extensionsToLabel('jpg,png,dng')).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'); + }); + }); +}); diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts index ba5514c9..f4881ec4 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -12,6 +12,9 @@ const EXTENSION_TO_MIME: Record = { 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', }; const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp'; @@ -46,3 +49,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(); + 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(', '); +}