Merge pull request #833 from PicPeak/feat/guest-upload-dng-raw
feat(uploads): DNG / camera-RAW support via embedded-preview extraction (#821)
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: () => {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -15,6 +15,8 @@ const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
// 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';
|
||||
|
||||
Reference in New Issue
Block a user