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 1/4] 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 e732e13f24c71ce091c6a82895d6526a67005c98 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:07:32 +0200 Subject: [PATCH 2/4] 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 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 3/4] 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 4/4] 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) {