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 = ({ eventId, onUploadCompl [settings?.general_allowed_file_types] ); + const formatsLabel = useMemo( + () => extensionsToLabel(settings?.general_allowed_file_types), + [settings?.general_allowed_file_types] + ); + + const maxFileSizeMb = Number.isFinite(Number(settings?.general_max_file_size_mb)) + ? Number(settings?.general_max_file_size_mb) + : 50; + const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0); const [isDragOver, setIsDragOver] = useState(false); @@ -511,7 +520,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl {t('upload.clickToUpload')}

- {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.clickToUpload')}

- {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 = { 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) — 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'; -/** - * Return the configured supported extensions, excluding values the upload - * pipeline cannot validate. Falls back to the default list when none match. - */ -export function getSupportedExtensions(extString?: string | null): string[] { - const input = extString?.trim() || DEFAULT_ALLOWED; - const extensions = input - .split(',') - .map(ext => ext.trim().toLowerCase().replace(/^\./, '')) - .filter(ext => Boolean(EXTENSION_TO_MIME[ext])); - - return extensions.length > 0 - ? Array.from(new Set(extensions)) - : getSupportedExtensions(DEFAULT_ALLOWED); -} - /** * Convert a comma-separated extension string (e.g. "jpg,png,mp4") to an * array of unique MIME types. */ export function extensionsToMimeTypes(extString?: string | null): string[] { - return Array.from(new Set( - getSupportedExtensions(extString).map(extension => EXTENSION_TO_MIME[extension]) - )); + const input = extString?.trim() || DEFAULT_ALLOWED; + const mimeSet = new Set(); + + input.split(',').forEach(ext => { + const cleaned = ext.trim().toLowerCase().replace(/^\./, ''); + const mime = EXTENSION_TO_MIME[cleaned]; + if (mime) { + mimeSet.add(mime); + } + }); + + if (mimeSet.size === 0) { + return extensionsToMimeTypes(DEFAULT_ALLOWED); + } + + return Array.from(mimeSet); } /** @@ -52,3 +51,24 @@ export function extensionsToMimeTypes(extString?: string | null): string[] { export function extensionsToAcceptString(extString?: string | null): string { return extensionsToMimeTypes(extString).join(','); } + +/** + * Human-readable, de-duplicated list of the configured extensions for the + * upload requirements hint, e.g. "JPG, PNG, WEBP, MOV". Only extensions the + * app actually supports (present in EXTENSION_TO_MIME) are shown, so the hint + * never advertises a format the backend would reject. + */ +export function extensionsToLabel(extString?: string | null): string { + const input = extString?.trim() || DEFAULT_ALLOWED; + const seen = new Set(); + const labels: string[] = []; + input.split(',').forEach(ext => { + const cleaned = ext.trim().toLowerCase().replace(/^\./, ''); + if (cleaned && EXTENSION_TO_MIME[cleaned] && !seen.has(cleaned)) { + seen.add(cleaned); + labels.push(cleaned.toUpperCase()); + } + }); + if (labels.length === 0) return extensionsToLabel(DEFAULT_ALLOWED); + return labels.join(', '); +}