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
- {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