From 2b5b23b96fb819d090c0a23fcffa69c35257b394 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Fri, 17 Jul 2026 21:39:34 +0200
Subject: [PATCH 1/3] feat(uploads): HEIC/HEIF support + dynamic format hint on
guest upload (#821)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two of the three things from #821:
- HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif`
input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips
8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both
the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which
are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection,
but a genuine .heic upload is now handled when it arrives.)
- The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New
extensionsToLabel() renders the actually-configured, supported formats (e.g.
"JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}}
across all 8 locales. Unsupported extensions are dropped from the label so it
never advertises a format the backend would reject.
DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader,
so a DNG would upload then fail thumbnailing (photo → 'failed', no preview).
Proper RAW support (embedded-preview extraction) is a separate PR.
Adds vitest coverage for extensionsToLabel + the HEIC mapping.
---
backend/src/services/uploadSettings.js | 5 +++
.../components/gallery/UserPhotoUpload.tsx | 11 ++++--
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/utils/__tests__/fileTypes.test.ts | 35 +++++++++++++++++++
frontend/src/utils/fileTypes.ts | 24 +++++++++++++
12 files changed, 81 insertions(+), 10 deletions(-)
create mode 100644 frontend/src/utils/__tests__/fileTypes.test.ts
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/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx
index 0f4ffef9..5aef8b43 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, extensionsToLabel } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
eventId: number;
@@ -61,6 +61,13 @@ export const UserPhotoUpload: React.FC
- {t('upload.fileRequirements', { limit: maxFilesPerUpload })} + {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
Date: Fri, 17 Jul 2026 22:21:43 +0200
Subject: [PATCH 3/3] fix(gallery): serve JPEG preview for non-displayable
originals in lightbox (codex review of #832)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The lightbox falls back to photo.url (the ORIGINAL) when preview_url is null,
which happens by default (lightbox_preview_enabled=false). For HEIC/HEIF/DNG the
original bytes aren't renderable in an , so the lightbox showed a broken
image. Now force preview_url for those formats (by MIME or extension) regardless
of the toggle, so the browser always gets the generated JPEG preview. Covers DNG
too (forward-compatible with #833).
EXPERIMENTAL caveat unchanged: whether the preview actually renders still depends
on the backend decoding the source — HEVC-in-HEIC on the prod Alpine image is
unverified, DNG needs exiftool (#833). Documented on the PR.
---
backend/src/routes/gallery.js | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
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}`