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 = ({ [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(', '); +} From 8e0005e170ce186550906f25c5977ff14ab1861d Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:45:29 +0200 Subject: [PATCH 04/11] chore(main): release 3.90.2-beta.0 (#826) --- .release-please-manifest-beta.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ backend/package.json | 2 +- frontend/package.json | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 0f65f8a0..34785653 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.90.1-beta.0" + ".": "3.90.2-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0f7ecf..63997c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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.90.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.1-beta.0...v3.90.2-beta.0) (2026-07-17) + + +### Bug Fixes + +* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([0245e44](https://github.com/PicPeak/picpeak/commit/0245e445cafd165ada3c5a15abb258ae2c1c857e)) +* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([b97b130](https://github.com/PicPeak/picpeak/commit/b97b130cadebaef38e59cc227fa6578ac886110f)) +* **update:** target docker-compose.production.yml in dashboard update steps ([51a505e](https://github.com/PicPeak/picpeak/commit/51a505e3798895e544f943673e81a365265f319c)) +* **update:** target docker-compose.production.yml in dashboard update steps + gate mailhog ([2a0361a](https://github.com/PicPeak/picpeak/commit/2a0361a83b4ca0a600bb4fd447e338533ce63420)) +* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([29f1d23](https://github.com/PicPeak/picpeak/commit/29f1d23a0a645208f22453e62d99fe79b55c7db4)) +* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([1e38d84](https://github.com/PicPeak/picpeak/commit/1e38d84808ee2a2b176c75d5ec4975fba710e63c)) +* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([43c6d22](https://github.com/PicPeak/picpeak/commit/43c6d22bdd93179865703da6350094c9b95388d8)) +* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([e03d13e](https://github.com/PicPeak/picpeak/commit/e03d13efde843c7a7275cd41c855b402538756e7)) + ## [3.90.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.0-beta.0...v3.90.1-beta.0) (2026-07-17) diff --git a/backend/package.json b/backend/package.json index 1eea21c5..abb783a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.90.1-beta.0", + "version": "3.90.2-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 52158a3e..82e330e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.90.1-beta.0", + "version": "3.90.2-beta.0", "type": "module", "scripts": { "dev": "vite", From be2ec0a4a1d03045c17c2a29e7fb8616f9de65f2 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:51:21 +0200 Subject: [PATCH 05/11] feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharp's bundled libvips has no raw loader, so a DNG can't be thumbnailed directly. This adds a preview-extraction step so RAW/DNG uploads get a proper thumbnail + gallery preview while the original RAW is kept for download. - imageProcessor: isRawFilename() + extractRawPreview() (exiftool extracts the embedded full-res JPEG — JpgFromRaw → PreviewImage → ThumbnailImage, validated with sharp) + withProcessableImage() which is a pass-through for ordinary images and swaps in the extracted JPEG for RAW. Wired into ingest (photoProcessor) and all three on-demand generators (ensureThumbnail/Hero/ Preview). generateHeroImage/generatePreviewImage gained outputBasename so RAW-derived outputs stay named after the source. - Dockerfile: add exiftool (confirmed present in Alpine v3.24 community). - Format maps: dng → image/x-adobe-dng in uploadSettings.js and fileTypes.ts; ALLOWED_MEDIA_TYPES gains a DNG entry (TIFF magic numbers) so it passes the security file-validator. Strictly gated by extension: nothing in this path runs for jpg/png/webp/etc, so existing photos are unaffected. If extraction fails (corrupt RAW, no embedded preview), the photo is marked 'failed' with a clear error — same as any unreadable upload. Verification boundary (please validate on a real DNG after the image rebuilds): the exiftool extraction itself couldn't be exercised in the dev sandbox (exiftool isn't a dev dependency and there's no DNG fixture). Unit tests cover the gating (RAW detection + non-RAW pass-through + clean failure without exiftool); existing processPhoto tests still pass. Known limitation: a DNG is only accepted when the browser reports its MIME as image/x-adobe-dng (Chrome does); browsers that send an empty type reject it client- and server-side — a follow-up can add extension-based acceptance for the RAW set. Companion to the HEIC/dynamic-hint PR; targets main only. --- backend/Dockerfile | 5 +- .../services/imageProcessorRaw.test.js | 50 ++++++++ backend/src/services/imageProcessor.js | 110 ++++++++++++++++-- backend/src/services/photoProcessor.js | 30 +++-- backend/src/services/uploadSettings.js | 5 + backend/src/utils/fileSecurityUtils.js | 13 +++ frontend/src/utils/fileTypes.ts | 2 + 7 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 backend/__tests__/services/imageProcessorRaw.test.js diff --git a/backend/Dockerfile b/backend/Dockerfile index 688820ed..2f595aac 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -67,8 +67,11 @@ RUN npm install -g npm@11 # malicious) PDF. pdftoppm does not execute embedded JS or fetch remote # resources, so it doubles as the SSRF/phone-home guard for untrusted inbound # documents (see docs/accounting-inbound-invoices.md). +# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads +# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline +# thumbnails/displays that preview while keeping the original for download. RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \ - fontconfig ttf-dejavu ttf-liberation poppler-utils && \ + fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \ fc-cache -f # Create non-root user diff --git a/backend/__tests__/services/imageProcessorRaw.test.js b/backend/__tests__/services/imageProcessorRaw.test.js new file mode 100644 index 00000000..4f770f3b --- /dev/null +++ b/backend/__tests__/services/imageProcessorRaw.test.js @@ -0,0 +1,50 @@ +/** + * Unit tests for the RAW/DNG handling helpers (#821). The actual exiftool + * extraction can only be exercised in the built image (exiftool isn't a dev + * dependency), so these cover the gating logic: which files are treated as RAW, + * and that ordinary images pass through untouched (zero cost / no extraction). + */ +const path = require('path'); +const { isRawFilename, withProcessableImage, RAW_EXTENSIONS } = require('../../src/services/imageProcessor'); + +describe('isRawFilename', () => { + it('recognises common RAW / DNG extensions', () => { + for (const ext of ['dng', 'cr2', 'cr3', 'nef', 'arw', 'raf', 'rw2', 'orf']) { + expect(isRawFilename(`IMG_1234.${ext}`)).toBe(true); + expect(isRawFilename(`IMG_1234.${ext.toUpperCase()}`)).toBe(true); // case-insensitive + } + }); + + it('does not treat ordinary images/videos as RAW', () => { + for (const name of ['photo.jpg', 'photo.jpeg', 'photo.png', 'photo.webp', 'clip.mp4', 'clip.mov', 'photo.heic']) { + expect(isRawFilename(name)).toBe(false); + } + }); + + it('is null/empty safe', () => { + expect(isRawFilename(null)).toBe(false); + expect(isRawFilename('')).toBe(false); + expect(isRawFilename('noextension')).toBe(false); + }); + + it('RAW_EXTENSIONS includes dng (Apple ProRAW)', () => { + expect(RAW_EXTENSIONS.has('dng')).toBe(true); + }); +}); + +describe('withProcessableImage', () => { + it('passes ordinary images through with no extraction and a no-op cleanup', async () => { + const localPath = '/tmp/whatever/photo.jpg'; + const proc = await withProcessableImage(localPath, 'photo.jpg'); + expect(proc.path).toBe(localPath); // unchanged — sharp reads it directly + expect(proc.outputBasename).toBeUndefined(); // generators keep their default naming + await expect(Promise.resolve(proc.cleanup())).resolves.toBeUndefined(); + }); + + it('routes RAW files to extraction (which fails cleanly without exiftool/preview)', async () => { + // In the dev sandbox exiftool isn't installed, so extraction throws — the + // caller turns that into a normal processing failure. In the built image + // (exiftool present) this instead returns the embedded JPEG preview. + await expect(withProcessableImage('/tmp/whatever/IMG_1234.dng', 'IMG_1234.dng')).rejects.toThrow(); + }); +}); diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 8334a202..fa154328 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -7,11 +7,80 @@ const crypto = require('crypto'); const logger = require('../utils/logger'); const { db } = require('../database/db'); const { getStorage } = require('./storage'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const execFileAsync = promisify(execFile); // Configure sharp for better memory management with large batches sharp.cache(false); // Disable cache to prevent memory buildup sharp.concurrency(2); // Limit concurrent operations +// Camera RAW / DNG formats. Sharp's bundled libvips has no raw loader, so these +// can't be fed to sharp() directly — instead we extract the full-resolution JPEG +// preview that every RAW file embeds (via exiftool) and process THAT. Gated +// strictly by extension, so nothing here runs for ordinary jpg/png/webp photos. +const RAW_EXTENSIONS = new Set([ + 'dng', 'cr2', 'cr3', 'nef', 'nrw', 'arw', 'sr2', 'srf', + 'raf', 'rw2', 'orf', 'pef', 'srw', 'raw', '3fr', 'dcr', 'kdc' +]); + +function isRawFilename(name) { + if (!name || typeof name !== 'string') return false; + const ext = path.extname(name).toLowerCase().replace(/^\./, ''); + return RAW_EXTENSIONS.has(ext); +} + +/** + * Extract the embedded full-resolution JPEG preview from a RAW/DNG file to a + * temp .jpg and return its path. Tries the largest previews first + * (JpgFromRaw → PreviewImage → ThumbnailImage). Throws if none can be extracted + * or the result isn't a valid image — the caller treats that as a processing + * failure (photo → 'failed'), same as any unreadable upload. + */ +async function extractRawPreview(rawPath) { + const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-raw-')); + const outPath = path.join(outDir, `${crypto.randomBytes(4).toString('hex')}.jpg`); + const tags = ['-JpgFromRaw', '-PreviewImage', '-ThumbnailImage']; + let lastErr; + for (const tag of tags) { + try { + // `-b` writes the raw tag bytes to stdout; -w isn't reliable across tags, + // so capture stdout as a buffer and write it ourselves. + const { stdout } = await execFileAsync('exiftool', ['-b', tag, rawPath], { + encoding: 'buffer', + maxBuffer: 256 * 1024 * 1024, + }); + if (stdout && stdout.length > 0) { + await fsp.writeFile(outPath, stdout); + // Validate it's a real, decodable image before handing it to the pipeline. + const meta = await sharp(outPath).metadata(); + if (meta.width && meta.height) { + return { path: outPath, cleanup: () => fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) }; + } + } + } catch (err) { + lastErr = err; + } + } + await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}); + throw new Error(`No usable embedded preview in RAW file ${path.basename(rawPath)}: ${lastErr ? lastErr.message : 'no preview tag returned data'}`); +} + +/** + * Give a Sharp-processable local image path for `localPath`. For ordinary + * images it's a pass-through (no cost). For RAW/DNG (by `sourceName` extension) + * it extracts the embedded JPEG preview and returns that, plus the basename to + * use for generated outputs so thumbnails/previews stay named after the source + * rather than the random temp file. Always call `cleanup()` when done. + */ +async function withProcessableImage(localPath, sourceName) { + if (!isRawFilename(sourceName)) { + return { path: localPath, outputBasename: undefined, cleanup: () => {} }; + } + const { path: previewPath, cleanup } = await extractRawPreview(localPath); + return { path: previewPath, outputBasename: path.basename(sourceName), cleanup }; +} + // Default thumbnail settings const DEFAULT_THUMBNAIL_WIDTH = 300; const DEFAULT_THUMBNAIL_HEIGHT = 300; @@ -297,9 +366,14 @@ async function ensureThumbnail(photo) { return null; } logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`); - newThumbnailPath = await withLocalCopy(sourceKey, (localPath) => - generateThumbnail(localPath, { regenerate: true }) - ); + newThumbnailPath = await withLocalCopy(sourceKey, async (localPath) => { + const proc = await withProcessableImage(localPath, sourceKey); + try { + return await generateThumbnail(proc.path, { regenerate: true, outputBasename: proc.outputBasename }); + } finally { + await proc.cleanup(); + } + }); } if (newThumbnailPath) { @@ -366,7 +440,7 @@ async function generateVideoPlaceholder(originalFilename, options = {}) { * Outputs a 1920x1080 image suitable for full-width hero sections */ async function generateHeroImage(imagePath, options = {}) { - const filename = path.basename(imagePath); + const filename = options.outputBasename || path.basename(imagePath); const heroFilename = `hero_${filename}`; const heroRelKey = path.posix.join('heroes', heroFilename); const storage = getStorage(); @@ -469,9 +543,14 @@ async function ensureHeroImage(photo) { logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`); } - const newHeroPath = await withLocalCopy(sourceKey, (localPath) => - generateHeroImage(localPath, { regenerate: true }) - ); + const newHeroPath = await withLocalCopy(sourceKey, async (localPath) => { + const proc = await withProcessableImage(localPath, sourceKey); + try { + return await generateHeroImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename }); + } finally { + await proc.cleanup(); + } + }); if (newHeroPath) { await db('photos') @@ -498,7 +577,7 @@ async function ensureHeroImage(photo) { * thumbnails or heroes. */ async function generatePreviewImage(imagePath, options = {}) { - const filename = path.basename(imagePath); + const filename = options.outputBasename || path.basename(imagePath); const previewFilename = `preview_${filename}`; const previewRelKey = path.posix.join('previews', previewFilename); const storage = getStorage(); @@ -599,9 +678,14 @@ async function ensurePreviewImage(photo) { logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`); } - const newPreviewPath = await withLocalCopy(sourceKey, (localPath) => - generatePreviewImage(localPath, { regenerate: true }) - ); + const newPreviewPath = await withLocalCopy(sourceKey, async (localPath) => { + const proc = await withProcessableImage(localPath, sourceKey); + try { + return await generatePreviewImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename }); + } finally { + await proc.cleanup(); + } + }); if (newPreviewPath) { await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath }); @@ -665,4 +749,8 @@ module.exports = { ensurePreviewImage, extractCaptureDate, withLocalCopy, + isRawFilename, + extractRawPreview, + withProcessableImage, + RAW_EXTENSIONS, }; diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js index 360592c6..eec86982 100644 --- a/backend/src/services/photoProcessor.js +++ b/backend/src/services/photoProcessor.js @@ -1,7 +1,7 @@ const path = require('path'); const fs = require('fs').promises; const { db } = require('../database/db'); -const { generateThumbnail, extractCaptureDate, withLocalCopy } = require('./imageProcessor'); +const { generateThumbnail, extractCaptureDate, withLocalCopy, withProcessableImage } = require('./imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor'); const { getStorage } = require('./storage'); @@ -145,18 +145,26 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ videoMetadata = result.metadata; thumbnailPath = result.thumbnailKey; } else { - thumbnailPath = await generateThumbnail(tempPath); + // 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); try { - const sharp = require('sharp'); - const metadata = await sharp(tempPath).metadata(); - if (metadata.width && metadata.height) { - imageMetadata = { - width: metadata.width, - height: metadata.height - }; + thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename }); + try { + const sharp = require('sharp'); + const metadata = await sharp(proc.path).metadata(); + if (metadata.width && metadata.height) { + imageMetadata = { + width: metadata.width, + height: metadata.height + }; + } + } catch (metadataError) { + logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message); } - } catch (metadataError) { - logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message); + } finally { + await proc.cleanup(); } } diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js index 06319cca..7a9944f7 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', + // Camera RAW / Apple ProRAW. Not sharp-decodable directly — the processing + // pipeline extracts the embedded JPEG preview (exiftool) for thumbnails/ + // display, keeping the original for download. Browsers send DNG as + // image/x-adobe-dng, image/tiff, or an empty type, so accept the common set. + 'dng': 'image/x-adobe-dng', }; 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..2a2b9587 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -79,6 +79,19 @@ const ALLOWED_IMAGE_TYPES = { extensions: ['.svg'], // SVG files are XML-based text files, so we skip magic number validation magicNumbers: null + }, + // Camera RAW / Apple ProRAW (#821). DNG is a TIFF container, so it carries the + // TIFF magic (little-endian "II*\0" or big-endian "MM\0*"). The pipeline can't + // sharp-decode it directly — it extracts the embedded JPEG preview (exiftool) + // for thumbnails/display while storing the original for download. Only reached + // when an admin adds `dng` to the allowed types AND the browser reports the + // DNG MIME (Chrome does; browsers that send an empty type won't get this far). + 'image/x-adobe-dng': { + extensions: ['.dng'], + 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*) + ] } }; diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts index ba5514c9..e222e6f5 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -12,6 +12,8 @@ const EXTENSION_TO_MIME: Record = { webm: 'video/webm', mov: 'video/quicktime', avi: 'video/x-msvideo', + // Camera RAW / Apple ProRAW — backend extracts the embedded JPEG preview. + dng: 'image/x-adobe-dng', }; const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp'; From c9b64d9c1a8744c9ee5e068366a500ae0dab36bc Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:06:40 +0200 Subject: [PATCH 06/11] fix(uploads): register HEIC/HEIF with the file validator + fix admin format hint (codex review of #832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the Codex review: - validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither image/heic nor image/heif — so HEIC was rejected before sharp ever saw it, despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp' (offset 4) magic number (the check is .every, so alternatives can't be separate entries). - Changing the shared upload.fileRequirements string to interpolate {{formats}} left the admin PhotoUpload caller passing only { limit }, rendering the placeholder literally (it was also already dropping {{sizeLimit}} from #823). The admin caller now passes formats + sizeLimit + limit, from the admin settings it already loads. --- backend/src/utils/fileSecurityUtils.js | 16 ++++++++++++++++ frontend/src/components/admin/PhotoUpload.tsx | 13 +++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 })}

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",