diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json
index 0f65f8a0..a2bdc4ce 100644
--- a/.release-please-manifest-beta.json
+++ b/.release-please-manifest-beta.json
@@ -1,3 +1,3 @@
{
- ".": "3.90.1-beta.0"
+ ".": "3.91.0-beta.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f0f7ecf..76d41d45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [3.91.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.2-beta.0...v3.91.0-beta.0) (2026-07-18)
+
+
+### Features
+
+* **uploads:** HEIC/HEIF support + dynamic format hint on guest upload ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([ee9d2f7](https://github.com/PicPeak/picpeak/commit/ee9d2f70d3342d65edb795a688f0f5f611429964))
+
+
+### Bug Fixes
+
+* **gallery:** serve JPEG preview for non-displayable originals in lightbox (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([808d305](https://github.com/PicPeak/picpeak/commit/808d3055497bb4e4a372acafa49ef9baf257f008))
+* **uploads:** register HEIC/HEIF with the file validator + fix admin format hint (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([c9b64d9](https://github.com/PicPeak/picpeak/commit/c9b64d9c1a8744c9ee5e068366a500ae0dab36bc))
+
+## [3.90.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.1-beta.0...v3.90.2-beta.0) (2026-07-17)
+
+
+### Bug Fixes
+
+* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([0245e44](https://github.com/PicPeak/picpeak/commit/0245e445cafd165ada3c5a15abb258ae2c1c857e))
+* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([b97b130](https://github.com/PicPeak/picpeak/commit/b97b130cadebaef38e59cc227fa6578ac886110f))
+* **update:** target docker-compose.production.yml in dashboard update steps ([51a505e](https://github.com/PicPeak/picpeak/commit/51a505e3798895e544f943673e81a365265f319c))
+* **update:** target docker-compose.production.yml in dashboard update steps + gate mailhog ([2a0361a](https://github.com/PicPeak/picpeak/commit/2a0361a83b4ca0a600bb4fd447e338533ce63420))
+* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([29f1d23](https://github.com/PicPeak/picpeak/commit/29f1d23a0a645208f22453e62d99fe79b55c7db4))
+* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([1e38d84](https://github.com/PicPeak/picpeak/commit/1e38d84808ee2a2b176c75d5ec4975fba710e63c))
+* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([43c6d22](https://github.com/PicPeak/picpeak/commit/43c6d22bdd93179865703da6350094c9b95388d8))
+* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([e03d13e](https://github.com/PicPeak/picpeak/commit/e03d13efde843c7a7275cd41c855b402538756e7))
+
## [3.90.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.0-beta.0...v3.90.1-beta.0) (2026-07-17)
diff --git a/backend/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__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js
index fdee970b..728ceb9e 100644
--- a/backend/__tests__/routes/adminEvents.smoke.test.js
+++ b/backend/__tests__/routes/adminEvents.smoke.test.js
@@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
+
+ // #822 — hero_logo_visible/position are nullable (null = "inherit the global
+ // branding toggle"), but the validator used .optional() without
+ // { nullable: true }, so an explicit null was rejected with 400.
+ it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
+ const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
+ const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
+ hero_logo_visible: null,
+ });
+ expect(res.status).toBe(200);
+ const row = await db('events').where({ id }).first();
+ expect(row.hero_logo_visible).toBeNull();
+ });
+
+ it('still rejects a non-boolean hero_logo_visible', async () => {
+ const id = await insertEvent(db, adminId);
+ const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
+ hero_logo_visible: 'maybe',
+ });
+ expect(res.status).toBe(400);
+ });
});
describe('DELETE /:id', () => {
diff --git a/backend/__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/package.json b/backend/package.json
index 1eea21c5..6f9a9c4f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
- "version": "3.90.1-beta.0",
+ "version": "3.91.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js
index d8dfbf13..56b8252f 100644
--- a/backend/src/routes/adminEvents/crud.js
+++ b/backend/src/routes/adminEvents/crud.js
@@ -94,7 +94,7 @@ module.exports = (router) => {
body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
- body('hero_logo_visible').optional().isBoolean(),
+ body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
@@ -342,8 +342,10 @@ module.exports = (router) => {
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
- // choice overrides the global.
- const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
+ // choice overrides the global. `!= null` treats an explicit null the same
+ // as omitted (both → inherit); otherwise formatBoolean(null) would coerce
+ // to 0/false on SQLite instead of NULL (the PUT handler already does this).
+ const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
@@ -1224,7 +1226,7 @@ module.exports = (router) => {
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
- body('hero_logo_visible').optional().isBoolean(),
+ body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js
index a46bc64d..5183a6bb 100644
--- a/backend/src/routes/adminSettings.js
+++ b/backend/src/routes/adminSettings.js
@@ -25,7 +25,7 @@ const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
-const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
+const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD, clearMaxFileSizeCache, MAX_ALLOWED_FILE_SIZE_MB } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
@@ -1095,6 +1095,24 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
settings.general_max_files_per_upload = normalizedValue;
}
+ // Per-file size limit (MB). Validate/clamp on save, mirroring the count
+ // above, so an out-of-range value can't be persisted — otherwise the public
+ // endpoint would advertise the raw value while getMaxFileSizeMb() normalizes
+ // it, and the guest UI would reject files the backend actually accepts.
+ if (Object.prototype.hasOwnProperty.call(settings, 'general_max_file_size_mb')) {
+ uploadLimitTouched = true;
+ const rawValue = Number(settings.general_max_file_size_mb);
+ const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
+
+ if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILE_SIZE_MB) {
+ return res.status(400).json({
+ error: `general_max_file_size_mb must be an integer between 1 and ${MAX_ALLOWED_FILE_SIZE_MB}`
+ });
+ }
+
+ settings.general_max_file_size_mb = normalizedValue;
+ }
+
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -1151,6 +1169,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
}
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
+ clearMaxFileSizeCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index adcdee5a..07fce2fe 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -38,6 +38,24 @@ const {
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { getStorage } = require('../services/storage');
+
+// Formats whose ORIGINAL bytes a browser can't render in an (HEIC/HEIF,
+// camera RAW/DNG). For these the lightbox must be served the generated JPEG
+// preview instead of `url` (the original) — otherwise it shows a broken image.
+// So we force `preview_url` for them regardless of the lightbox_preview_enabled
+// toggle. Detection is by MIME first, extension as a fallback (browsers report
+// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders
+// still depends on the backend being able to decode the source (HEVC-in-HEIC on
+// the prod image; exiftool for DNG) — see #821.
+const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']);
+const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']);
+function originalNeedsPreview(photo) {
+ const mime = (photo.mime_type || '').toLowerCase();
+ if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true;
+ const name = photo.original_filename || photo.filename || '';
+ const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
+ return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext);
+}
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
// Read globals from app_settings (the real table) — settingsService.getSetting
// queries a non-existent `settings` table and throws.
@@ -726,7 +744,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
- preview_url: lightboxPreviewEnabled
+ preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
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 623a9966..13062298 100644
--- a/backend/src/services/uploadSettings.js
+++ b/backend/src/services/uploadSettings.js
@@ -24,14 +24,21 @@ const EXTENSION_TO_MIME = {
'png': 'image/png',
'webp': 'image/webp',
'gif': 'image/gif',
- 'dng': 'image/x-adobe-dng',
- 'heic': 'image/heic',
- 'heif': 'image/heif',
'mp4': 'video/mp4',
'm4v': 'video/mp4',
'webm': 'video/webm',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
+ // HEIC/HEIF (iPhone). Sharp's bundled libvips decodes `heif` input, so
+ // thumbnails generate fine. (iOS Safari usually transcodes to JPEG at file
+ // selection, but a genuine .heic upload is handled when it does arrive.)
+ 'heic': 'image/heic',
+ 'heif': 'image/heif',
+ // 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 afe4f095..599a3425 100644
--- a/backend/src/utils/fileSecurityUtils.js
+++ b/backend/src/utils/fileSecurityUtils.js
@@ -75,24 +75,43 @@ const ALLOWED_IMAGE_TYPES = {
{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] } // GIF89a
]
},
- // DNG and HEIF-family files use TIFF/ISO Base Media File Format containers,
- // so their extension and declared MIME type are validated together here.
- 'image/x-adobe-dng': {
- extensions: ['.dng'],
- magicNumbers: null
- },
- 'image/heic': {
- extensions: ['.heic'],
- magicNumbers: null
- },
- 'image/heif': {
- extensions: ['.heif'],
- magicNumbers: null
- },
'image/svg+xml': {
extensions: ['.svg'],
// SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null
+ },
+ // HEIC/HEIF (iPhone). ISO-BMFF container: bytes 4-7 are the "ftyp" box marker,
+ // present in every HEIF/HEIC file (single entry — the magic check is `.every`,
+ // so alternatives can't be listed as separate entries). Sharp's libvips
+ // decodes these; extension + MIME are already gated by validateFileType.
+ 'image/heic': {
+ extensions: ['.heic'],
+ magicNumbers: [
+ { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
+ ]
+ },
+ 'image/heif': {
+ extensions: ['.heif'],
+ magicNumbers: [
+ { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
+ ]
+ },
+ // 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/package.json b/frontend/package.json
index 52158a3e..1b99e554 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
- "version": "3.90.1-beta.0",
+ "version": "3.91.0-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx
index 7d3f0ea1..b3a7d0a1 100644
--- a/frontend/src/components/admin/PhotoUpload.tsx
+++ b/frontend/src/components/admin/PhotoUpload.tsx
@@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
-import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
+import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps {
@@ -118,6 +118,15 @@ export const PhotoUpload: React.FC
- {t('upload.fileRequirements', { limit: maxFilesPerUpload })} + {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
= ({
[publicSettings?.allowed_file_types]
);
- const allowedExtensions = useMemo(
- () => getSupportedExtensions(publicSettings?.allowed_file_types),
+ // #821 — the requirements hint used to hardcode "JPEG, PNG or WebP"; render
+ // the actually-configured formats so it never contradicts what's accepted.
+ const formatsLabel = useMemo(
+ () => extensionsToLabel(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
);
@@ -238,11 +240,10 @@ export const UserPhotoUpload: React.FC
- {t('upload.fileRequirements', { - types: allowedExtensions.map(extension => extension.toUpperCase()).join(', '), - limit: maxFilesPerUpload, - sizeLimit: maxFileSizeMb, - })} + {/* #613 — pass { limit } so `{{limit}}` interpolates + with the real number from settings instead of + rendering literally. */} + {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
{ - it('keeps configured supported extensions for the upload hint', () => { - expect(getSupportedExtensions('jpg, dng, heic, heif, mov, mp4, .webp')).toEqual([ - 'jpg', - 'dng', - 'heic', - 'heif', - 'mov', - 'mp4', - 'webp', - ]); +describe('fileTypes', () => { + describe('extensionsToMimeTypes', () => { + it('maps known extensions to MIME types', () => { + expect(extensionsToMimeTypes('jpg,png,mov')).toEqual(['image/jpeg', 'image/png', 'video/quicktime']); + }); + it('supports HEIC/HEIF (#821)', () => { + expect(extensionsToMimeTypes('heic,heif')).toEqual(['image/heic', 'image/heif']); + }); + it('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('abc,xyz')).toEqual(['image/jpeg', 'image/png', 'image/webp']); + }); }); - it('uses the same configured types for validation and file selection', () => { - expect(extensionsToMimeTypes('jpg,jpeg,dng,heic,heif,mov,mp4')).toEqual([ - 'image/jpeg', - 'image/x-adobe-dng', - 'image/heic', - 'image/heif', - 'video/quicktime', - 'video/mp4', - ]); - expect(extensionsToAcceptString('dng,heic,heif')).toBe( - 'image/x-adobe-dng,image/heic,image/heif' - ); + describe('extensionsToLabel', () => { + it('renders a de-duplicated, upper-cased list of the configured formats', () => { + expect(extensionsToLabel('jpg,jpeg,png,webp,mov')).toBe('JPG, JPEG, PNG, WEBP, MOV'); + }); + it('only lists supported extensions (drops unknowns like 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'); + expect(extensionsToLabel(null)).toBe('JPG, JPEG, PNG, WEBP'); + }); }); - it('falls back to the default formats when no configured types are supported', () => { - expect(getSupportedExtensions('unknown')).toEqual([ - 'jpg', - 'jpeg', - 'png', - 'webp', - ]); + describe('extensionsToAcceptString', () => { + it('joins MIME types for the input accept attribute', () => { + expect(extensionsToAcceptString('jpg,heic')).toBe('image/jpeg,image/heic'); + }); }); }); diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts index cd2ff22f..34e42680 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -7,42 +7,41 @@ const EXTENSION_TO_MIME: Record