feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821)

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.
This commit is contained in:
Paul Nothaft
2026-07-17 21:51:21 +02:00
parent 2a0361a83b
commit be2ec0a4a1
7 changed files with 192 additions and 23 deletions
+4 -1
View File
@@ -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
@@ -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();
});
});
+99 -11
View File
@@ -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,
};
+19 -11
View File
@@ -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();
}
}
+5
View File
@@ -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';
+13
View File
@@ -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*)
]
}
};
+2
View File
@@ -12,6 +12,8 @@ const EXTENSION_TO_MIME: Record<string, string> = {
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';