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/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/__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/__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/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..5820cb1f 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,28 @@ 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). + // 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 { - 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(); } } @@ -457,21 +467,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..1944aeae 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,32 @@ 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; + // 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 { - 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 diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js index e102b7c8..13062298 100644 --- a/backend/src/services/uploadSettings.js +++ b/backend/src/services/uploadSettings.js @@ -34,6 +34,11 @@ const EXTENSION_TO_MIME = { // selection, but a genuine .heic upload is handled when it does arrive.) 'heic': 'image/heic', 'heif': 'image/heif', + // 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/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) { diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index 28155b33..5eec0f0f 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -95,6 +95,23 @@ const ALLOWED_IMAGE_TYPES = { magicNumbers: [ { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp" ] + }, + // 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'], + // 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) + ] } }; diff --git a/frontend/src/utils/__tests__/fileTypes.test.ts b/frontend/src/utils/__tests__/fileTypes.test.ts index a11e69f5..551f2e14 100644 --- a/frontend/src/utils/__tests__/fileTypes.test.ts +++ b/frontend/src/utils/__tests__/fileTypes.test.ts @@ -9,8 +9,11 @@ describe('fileTypes', () => { it('supports HEIC/HEIF (#821)', () => { expect(extensionsToMimeTypes('heic,heif')).toEqual(['image/heic', 'image/heif']); }); + it('supports DNG (#821)', () => { + expect(extensionsToMimeTypes('dng')).toEqual(['image/x-adobe-dng']); + }); it('drops unknown extensions and falls back to default when nothing maps', () => { - expect(extensionsToMimeTypes('dng,xyz')).toEqual(['image/jpeg', 'image/png', 'image/webp']); + expect(extensionsToMimeTypes('abc,xyz')).toEqual(['image/jpeg', 'image/png', 'image/webp']); }); }); @@ -18,8 +21,8 @@ describe('fileTypes', () => { 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('only lists supported extensions (drops unknowns like xyz)', () => { + expect(extensionsToLabel('jpg,png,xyz')).toBe('JPG, PNG'); }); it('falls back to the default set when empty', () => { expect(extensionsToLabel('')).toBe('JPG, JPEG, PNG, WEBP'); diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts index f4881ec4..34e42680 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -15,6 +15,8 @@ const EXTENSION_TO_MIME: Record = { // HEIC/HEIF (iPhone) — kept in sync with the backend EXTENSION_TO_MIME. heic: 'image/heic', heif: 'image/heif', + // Camera RAW / Apple ProRAW — backend extracts the embedded JPEG preview. + dng: 'image/x-adobe-dng', }; const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp';