From b97b130cadebaef38e59cc227fa6578ac886110f Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Fri, 17 Jul 2026 21:13:39 +0200
Subject: [PATCH 01/11] fix(events): accept hero_logo_visible: null on
create/update (#822)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.
- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
null stores NULL (inherit) rather than being coerced to 0/false by
formatBoolean on SQLite. The update handler already did `=== null ? null`.
Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.
Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
---
.../routes/adminEvents.smoke.test.js | 21 +++++++++++++++++++
backend/src/routes/adminEvents/crud.js | 10 +++++----
2 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/backend/__tests__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js
index fdee970b..728ceb9e 100644
--- a/backend/__tests__/routes/adminEvents.smoke.test.js
+++ b/backend/__tests__/routes/adminEvents.smoke.test.js
@@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
+
+ // #822 — hero_logo_visible/position are nullable (null = "inherit the global
+ // branding toggle"), but the validator used .optional() without
+ // { nullable: true }, so an explicit null was rejected with 400.
+ it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
+ const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
+ const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
+ hero_logo_visible: null,
+ });
+ expect(res.status).toBe(200);
+ const row = await db('events').where({ id }).first();
+ expect(row.hero_logo_visible).toBeNull();
+ });
+
+ it('still rejects a non-boolean hero_logo_visible', async () => {
+ const id = await insertEvent(db, adminId);
+ const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
+ hero_logo_visible: 'maybe',
+ });
+ expect(res.status).toBe(400);
+ });
});
describe('DELETE /:id', () => {
diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js
index d8dfbf13..56b8252f 100644
--- a/backend/src/routes/adminEvents/crud.js
+++ b/backend/src/routes/adminEvents/crud.js
@@ -94,7 +94,7 @@ module.exports = (router) => {
body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
- body('hero_logo_visible').optional().isBoolean(),
+ body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
@@ -342,8 +342,10 @@ module.exports = (router) => {
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
- // choice overrides the global.
- const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
+ // choice overrides the global. `!= null` treats an explicit null the same
+ // as omitted (both → inherit); otherwise formatBoolean(null) would coerce
+ // to 0/false on SQLite instead of NULL (the PUT handler already does this).
+ const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
@@ -1224,7 +1226,7 @@ module.exports = (router) => {
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
- body('hero_logo_visible').optional().isBoolean(),
+ body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
From e03d13efde843c7a7275cd41c855b402538756e7 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Fri, 17 Jul 2026 21:30:48 +0200
Subject: [PATCH 02/11] fix(uploads): tighten guest max-file-size setting
(codex review of #823)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three follow-ups from the Codex review of #823:
1. PublicSettings TypeScript interface was missing general_max_file_size_mb,
so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI
didn't catch it because the pipeline runs `build` (esbuild, no typecheck),
but it's a real type gap — the #614 count field is declared, this one wasn't.
Added the optional numeric field.
2. The general-settings update endpoint validated general_max_files_per_upload
but not general_max_file_size_mb, so an out-of-range value (0, -1, huge)
could persist. publicSettings then advertised the raw value while
getMaxFileSizeMb() normalised it — the guest UI would reject files the
backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB).
3. The update route cleared the file-count cache but not the new file-size
cache, so for up to 60s the public endpoint could advertise a new limit
while multer still enforced the old one. Now clears both under the same
uploadLimitTouched guard.
Follow-up on the merged #823 (main-only), so this targets main only.
---
backend/src/routes/adminSettings.js | 21 ++++++++++++++++++-
.../src/services/publicSettings.service.ts | 3 +++
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js
index a46bc64d..5183a6bb 100644
--- a/backend/src/routes/adminSettings.js
+++ b/backend/src/routes/adminSettings.js
@@ -25,7 +25,7 @@ const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
-const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
+const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD, clearMaxFileSizeCache, MAX_ALLOWED_FILE_SIZE_MB } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
@@ -1095,6 +1095,24 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
settings.general_max_files_per_upload = normalizedValue;
}
+ // Per-file size limit (MB). Validate/clamp on save, mirroring the count
+ // above, so an out-of-range value can't be persisted — otherwise the public
+ // endpoint would advertise the raw value while getMaxFileSizeMb() normalizes
+ // it, and the guest UI would reject files the backend actually accepts.
+ if (Object.prototype.hasOwnProperty.call(settings, 'general_max_file_size_mb')) {
+ uploadLimitTouched = true;
+ const rawValue = Number(settings.general_max_file_size_mb);
+ const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
+
+ if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILE_SIZE_MB) {
+ return res.status(400).json({
+ error: `general_max_file_size_mb must be an integer between 1 and ${MAX_ALLOWED_FILE_SIZE_MB}`
+ });
+ }
+
+ settings.general_max_file_size_mb = normalizedValue;
+ }
+
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -1151,6 +1169,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
}
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
+ clearMaxFileSizeCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts
index 1ce62b08..f7e461fe 100644
--- a/frontend/src/services/publicSettings.service.ts
+++ b/frontend/src/services/publicSettings.service.ts
@@ -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;
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 03/11] 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:07:32 +0200
Subject: [PATCH 07/11] fix(uploads): DNG magic must be a single entry (.every
validation)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The magic-number check in validateFileContent uses .every(), so the two
endianness entries (II + MM) could never both match — an admin DNG upload would
be rejected at content validation. Use the little-endian II magic only (Apple
ProRAW / camera DNGs); a rare big-endian DNG fails the check and is rejected,
which is safe since the embedded-preview extraction validates real content.
---
backend/src/utils/fileSecurityUtils.js | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js
index 2a2b9587..b2090f22 100644
--- a/backend/src/utils/fileSecurityUtils.js
+++ b/backend/src/utils/fileSecurityUtils.js
@@ -88,9 +88,13 @@ const ALLOWED_IMAGE_TYPES = {
// DNG MIME (Chrome does; browsers that send an empty type won't get this far).
'image/x-adobe-dng': {
extensions: ['.dng'],
+ // Single entry: the magic check is `.every`, so listing both endianness
+ // variants would require BOTH to match (impossible). DNG is TIFF; Apple
+ // ProRAW and virtually all camera DNGs are little-endian ("II*\0"). A rare
+ // big-endian DNG would fail this check and be rejected — acceptable, since
+ // the embedded-preview extraction validates the real content downstream.
magicNumbers: [
- { offset: 0, bytes: [0x49, 0x49, 0x2A, 0x00] }, // little-endian TIFF (II*\0)
- { offset: 0, bytes: [0x4D, 0x4D, 0x00, 0x2A] } // big-endian TIFF (MM\0*)
+ { offset: 0, bytes: [0x49, 0x49, 0x2A, 0x00] } // little-endian TIFF (II*\0)
]
}
};
From 808d3055497bb4e4a372acafa49ef9baf257f008 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Fri, 17 Jul 2026 22:21:43 +0200
Subject: [PATCH 08/11] 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}`
From b743ea0398c7ec0178cf29107629a7877442c494 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Fri, 17 Jul 2026 22:35:11 +0200
Subject: [PATCH 09/11] fix(uploads): apply RAW extraction in the actual async
ingest path (codex review of #833)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The RAW/DNG extraction was only wired into processUploadedPhotos() (the
synchronous path), but real uploads queue to 'pending' and are handled by the
background worker → processPhoto(), which generated the thumbnail + dimensions
directly from the DNG (both fail) and then marked the photo 'complete' — success
with no thumbnail. Wire withProcessableImage() into processPhoto() (the live
path) and into photoReplacementService.replacePhoto() (replace-by-name), so all
three ingest paths extract the embedded JPEG preview for RAW.
Updates the processPhoto test's imageProcessor mock with the new
withProcessableImage dependency (pass-through for ordinary images).
---
.../photoProcessor.processPhoto.test.js | 7 ++++
backend/src/services/photoProcessor.js | 36 ++++++++++++-------
.../src/services/photoReplacementService.js | 36 +++++++++++--------
3 files changed, 51 insertions(+), 28 deletions(-)
diff --git a/backend/__tests__/services/photoProcessor.processPhoto.test.js b/backend/__tests__/services/photoProcessor.processPhoto.test.js
index 763b213e..d5935300 100644
--- a/backend/__tests__/services/photoProcessor.processPhoto.test.js
+++ b/backend/__tests__/services/photoProcessor.processPhoto.test.js
@@ -75,6 +75,13 @@ jest.mock('../../src/services/imageProcessor', () => {
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),
+ // Pass-through for ordinary (non-RAW) images: returns the path unchanged
+ // with a no-op cleanup, matching the real helper's behaviour for jpg/png.
+ withProcessableImage: jest.fn(async (localPath) => ({
+ path: localPath,
+ outputBasename: undefined,
+ cleanup: () => {},
+ })),
};
});
diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js
index eec86982..986df36a 100644
--- a/backend/src/services/photoProcessor.js
+++ b/backend/src/services/photoProcessor.js
@@ -465,21 +465,31 @@ async function processPhoto(photoId) {
if (result.metadata.height) updateData.height = result.metadata.height;
}
} else {
+ // RAW/DNG can't be sharp-decoded directly — extract the embedded JPEG
+ // preview and thumbnail/measure that. Pass-through for ordinary images.
+ // This is the ASYNC worker path (backgroundProcessor → processPhoto), the
+ // one real uploads actually take; the synchronous processUploadedPhotos()
+ // has the same handling.
+ const proc = await withProcessableImage(localPath, photo.filename);
try {
- const thumbnailPath = await generateThumbnail(localPath);
- if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
- } catch (e) {
- logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
- }
- try {
- const sharp = require('sharp');
- const metadata = await sharp(localPath).metadata();
- if (metadata.width && metadata.height) {
- updateData.width = metadata.width;
- updateData.height = metadata.height;
+ try {
+ const thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
+ if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
+ } catch (e) {
+ logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
}
- } catch (e) {
- logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
+ try {
+ const sharp = require('sharp');
+ const metadata = await sharp(proc.path).metadata();
+ if (metadata.width && metadata.height) {
+ updateData.width = metadata.width;
+ updateData.height = metadata.height;
+ }
+ } catch (e) {
+ logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
+ }
+ } finally {
+ await proc.cleanup();
}
}
});
diff --git a/backend/src/services/photoReplacementService.js b/backend/src/services/photoReplacementService.js
index 3680cbbd..8799825c 100644
--- a/backend/src/services/photoReplacementService.js
+++ b/backend/src/services/photoReplacementService.js
@@ -10,7 +10,7 @@ const path = require('path');
const fsp = require('fs/promises');
const sharp = require('sharp');
const { db } = require('../database/db');
-const { generateThumbnail, extractCaptureDate } = require('./imageProcessor');
+const { generateThumbnail, extractCaptureDate, withProcessableImage } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const watermarkGeneratorService = require('./watermarkGeneratorService');
const { getStorage } = require('./storage');
@@ -61,24 +61,30 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
// No EXIF — keep null
}
- let width = null;
- let height = null;
- try {
- const metadata = await sharp(newFileTempPath).metadata();
- width = metadata.width || null;
- height = metadata.height || null;
- } catch {
- // Non-image or corrupt
- }
-
const stats = await fsp.stat(newFileTempPath);
- // Generate new thumbnail FROM the local temp before uploading the original.
+ // RAW/DNG isn't sharp-decodable — extract the embedded JPEG preview first
+ // (pass-through for ordinary images), then measure + thumbnail that. Mirrors
+ // the ingest paths (processPhoto / processUploadedPhotos).
+ let width = null;
+ let height = null;
let thumbnailPath = null;
+ const proc = await withProcessableImage(newFileTempPath, originalFilename);
try {
- thumbnailPath = await generateThumbnail(newFileTempPath);
- } catch {
- logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
+ try {
+ const metadata = await sharp(proc.path).metadata();
+ width = metadata.width || null;
+ height = metadata.height || null;
+ } catch {
+ // Non-image or corrupt
+ }
+ try {
+ thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
+ } catch {
+ logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
+ }
+ } finally {
+ await proc.cleanup();
}
// Delete old assets BEFORE uploading the new key — if they share the path
From d0ccadbc99e510d124814701faa410b3099792ed Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Fri, 17 Jul 2026 22:50:24 +0200
Subject: [PATCH 10/11] fix(uploads): RAW derivative key collision, watermark
skip, dev exiftool (codex review of #833 round 2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Derivative key collision: processUploadedPhotos/replacePhoto passed the
client-supplied original filename as the RAW output basename, but thumbnails/
heroes/previews are global keys — two galleries uploading IMG_0001.dng would
overwrite each other's derivative. Use the unique stored newFilename instead.
(processPhoto already used the unique photo.filename.)
- Watermark: the watermark path opens the original with sharp, which can't decode
RAW, so it fell back to the original bytes and recorded the copy as watermarked.
Skip RAW in generateForPhoto (like videos) so the watermark state stays honest
until RAW watermarking is properly supported.
- exiftool added to Dockerfile.dev so dev/native runtimes don't accept a DNG then
fail it with ENOENT.
---
backend/Dockerfile.dev | 5 ++++-
backend/src/services/photoProcessor.js | 4 +++-
backend/src/services/photoReplacementService.js | 4 +++-
backend/src/services/watermarkGeneratorService.js | 10 +++++++++-
4 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev
index 71d62c7f..a2ef40fc 100644
--- a/backend/Dockerfile.dev
+++ b/backend/Dockerfile.dev
@@ -8,7 +8,10 @@ RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
-RUN apk add --no-cache dumb-init ffmpeg
+# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
+# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
+# and then fail it with ENOENT.
+RUN apk add --no-cache dumb-init ffmpeg exiftool
# Copy package files
COPY package*.json ./
diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js
index 986df36a..5820cb1f 100644
--- a/backend/src/services/photoProcessor.js
+++ b/backend/src/services/photoProcessor.js
@@ -148,7 +148,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// RAW/DNG can't be fed to sharp directly (no raw loader), so extract the
// embedded JPEG preview first and thumbnail/measure THAT. Pass-through
// for ordinary images. The stored original stays the RAW (download).
- const proc = await withProcessableImage(tempPath, file.originalname);
+ // Use the unique stored filename (not the client-supplied original) so
+ // the RAW-derived thumbnail's global key can't collide across galleries.
+ const proc = await withProcessableImage(tempPath, newFilename);
try {
thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
try {
diff --git a/backend/src/services/photoReplacementService.js b/backend/src/services/photoReplacementService.js
index 8799825c..1944aeae 100644
--- a/backend/src/services/photoReplacementService.js
+++ b/backend/src/services/photoReplacementService.js
@@ -69,7 +69,9 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
let width = null;
let height = null;
let thumbnailPath = null;
- const proc = await withProcessableImage(newFileTempPath, originalFilename);
+ // Detect/name by the unique stored filename (newFilename), not the
+ // client-supplied original, so RAW derivative keys can't collide.
+ const proc = await withProcessableImage(newFileTempPath, newFilename);
try {
try {
const metadata = await sharp(proc.path).metadata();
diff --git a/backend/src/services/watermarkGeneratorService.js b/backend/src/services/watermarkGeneratorService.js
index 1424de37..746e55aa 100644
--- a/backend/src/services/watermarkGeneratorService.js
+++ b/backend/src/services/watermarkGeneratorService.js
@@ -11,7 +11,7 @@
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
-const { withLocalCopy } = require('./imageProcessor');
+const { withLocalCopy, isRawFilename } = require('./imageProcessor');
const logger = require('../utils/logger');
class WatermarkGeneratorService {
@@ -52,6 +52,14 @@ class WatermarkGeneratorService {
return { success: false, error: 'Videos do not support watermarks' };
}
+ // Skip RAW/DNG (experimental, #821). The watermark path opens the original
+ // with sharp, which can't decode RAW — proceeding would fall back to the
+ // original bytes and falsely record the copy as watermarked. Skipping keeps
+ // the watermark state honest until RAW watermarking is properly supported.
+ if (isRawFilename(photo.filename)) {
+ return { success: false, error: 'RAW/DNG files are not watermarked yet' };
+ }
+
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
From ec69ad84f2439d74aea1e3588e9c5e841bd08540 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Sat, 18 Jul 2026 20:52:51 +0200
Subject: [PATCH 11/11] chore(main): release 3.91.0-beta.0 (#835)
---
.release-please-manifest-beta.json | 2 +-
CHANGELOG.md | 13 +++++++++++++
backend/package.json | 2 +-
frontend/package.json | 2 +-
4 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json
index 34785653..a2bdc4ce 100644
--- a/.release-please-manifest-beta.json
+++ b/.release-please-manifest-beta.json
@@ -1,3 +1,3 @@
{
- ".": "3.90.2-beta.0"
+ ".": "3.91.0-beta.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63997c75..76d41d45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,19 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [3.91.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.2-beta.0...v3.91.0-beta.0) (2026-07-18)
+
+
+### Features
+
+* **uploads:** HEIC/HEIF support + dynamic format hint on guest upload ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([ee9d2f7](https://github.com/PicPeak/picpeak/commit/ee9d2f70d3342d65edb795a688f0f5f611429964))
+
+
+### Bug Fixes
+
+* **gallery:** serve JPEG preview for non-displayable originals in lightbox (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([808d305](https://github.com/PicPeak/picpeak/commit/808d3055497bb4e4a372acafa49ef9baf257f008))
+* **uploads:** register HEIC/HEIF with the file validator + fix admin format hint (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([c9b64d9](https://github.com/PicPeak/picpeak/commit/c9b64d9c1a8744c9ee5e068366a500ae0dab36bc))
+
## [3.90.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.1-beta.0...v3.90.2-beta.0) (2026-07-17)
diff --git a/backend/package.json b/backend/package.json
index abb783a9..6f9a9c4f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
- "version": "3.90.2-beta.0",
+ "version": "3.91.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
diff --git a/frontend/package.json b/frontend/package.json
index 82e330e0..1b99e554 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
- "version": "3.90.2-beta.0",
+ "version": "3.91.0-beta.0",
"type": "module",
"scripts": {
"dev": "vite",