From b97b130cadebaef38e59cc227fa6578ac886110f Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:13:39 +0200 Subject: [PATCH 1/6] fix(events): accept hero_logo_visible: null on create/update (#822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hero_logo_visible is nullable — null means "inherit the global branding_logo_display_hero toggle" (#756, migration 152). But the create and update validators used `.optional()` without `{ nullable: true }`, which only skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the inherit state the frontend sends) was rejected on v3.45.2. - Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`, matching the already-correct `hero_logo_size` rule next to it. - Create handler: guard on `!= null` instead of `!== undefined` so an explicit null stores NULL (inherit) rather than being coerced to 0/false by formatBoolean on SQLite. The update handler already did `=== null ? null`. Left hero_logo_position on plain `.optional()` on purpose: its column is NOT NULL (no inherit migration) and its handler always resolves to a concrete value via `|| brandingDefaults`, so null is genuinely invalid there — allowing it would trade the 400 for a 500. Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a non-boolean value is still rejected. --- .../routes/adminEvents.smoke.test.js | 21 +++++++++++++++++++ backend/src/routes/adminEvents/crud.js | 10 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) 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/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) From e03d13efde843c7a7275cd41c855b402538756e7 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:30:48 +0200 Subject: [PATCH 2/6] fix(uploads): tighten guest max-file-size setting (codex review of #823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from the Codex review of #823: 1. PublicSettings TypeScript interface was missing general_max_file_size_mb, so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI didn't catch it because the pipeline runs `build` (esbuild, no typecheck), but it's a real type gap — the #614 count field is declared, this one wasn't. Added the optional numeric field. 2. The general-settings update endpoint validated general_max_files_per_upload but not general_max_file_size_mb, so an out-of-range value (0, -1, huge) could persist. publicSettings then advertised the raw value while getMaxFileSizeMb() normalised it — the guest UI would reject files the backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB). 3. The update route cleared the file-count cache but not the new file-size cache, so for up to 60s the public endpoint could advertise a new limit while multer still enforced the old one. Now clears both under the same uploadLimitTouched guard. Follow-up on the merged #823 (main-only), so this targets main only. --- backend/src/routes/adminSettings.js | 21 ++++++++++++++++++- .../src/services/publicSettings.service.ts | 3 +++ 2 files changed, 23 insertions(+), 1 deletion(-) 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/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index 1ce62b08..f7e461fe 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -82,6 +82,9 @@ export interface PublicSettings { // modal can render the real number in `upload.fileRequirements` and refuse // oversized batches client-side. Backend enforces the same value too. general_max_files_per_upload?: number; + // #613 follow-up — per-file size limit (MB), surfaced so the guest upload + // modal shows the real limit and guards client-side. Backend enforces it too. + general_max_file_size_mb?: number; // Event field requirements event_require_customer_name?: boolean; event_require_customer_email?: boolean; From 2b5b23b96fb819d090c0a23fcffa69c35257b394 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:39:34 +0200 Subject: [PATCH 3/6] feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three things from #821: - HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif` input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips 8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection, but a genuine .heic upload is now handled when it arrives.) - The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New extensionsToLabel() renders the actually-configured, supported formats (e.g. "JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}} across all 8 locales. Unsupported extensions are dropped from the label so it never advertises a format the backend would reject. DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader, so a DNG would upload then fail thumbnailing (photo → 'failed', no preview). Proper RAW support (embedded-preview extraction) is a separate PR. Adds vitest coverage for extensionsToLabel + the HEIC mapping. --- backend/src/services/uploadSettings.js | 5 +++ .../components/gallery/UserPhotoUpload.tsx | 11 ++++-- frontend/src/i18n/locales/de.json | 2 +- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/es.json | 2 +- frontend/src/i18n/locales/fr.json | 2 +- frontend/src/i18n/locales/nl.json | 2 +- frontend/src/i18n/locales/pt.json | 2 +- frontend/src/i18n/locales/ru.json | 2 +- frontend/src/i18n/locales/sl.json | 2 +- .../src/utils/__tests__/fileTypes.test.ts | 35 +++++++++++++++++++ frontend/src/utils/fileTypes.ts | 24 +++++++++++++ 12 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 frontend/src/utils/__tests__/fileTypes.test.ts diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js index 06319cca..e102b7c8 100644 --- a/backend/src/services/uploadSettings.js +++ b/backend/src/services/uploadSettings.js @@ -29,6 +29,11 @@ const EXTENSION_TO_MIME = { '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', }; const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp'; diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 0f4ffef9..5aef8b43 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -5,7 +5,7 @@ import { toast } from 'react-toastify'; import { Button } from '../common'; import { api } from '../../config/api'; import { usePublicSettings } from '../../hooks/usePublicSettings'; -import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes'; +import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes'; interface UserPhotoUploadProps { eventId: number; @@ -61,6 +61,13 @@ export const UserPhotoUpload: React.FC = ({ [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] + ); + // Shared filter pipeline for both change and drag-and-drop (#504). const addFiles = (incoming: File[]) => { const validFiles = incoming.filter((file) => { @@ -236,7 +243,7 @@ export const UserPhotoUpload: React.FC = ({ {/* #613 — pass { limit } so `{{limit}}` interpolates with the real number from settings instead of rendering literally. */} - {t('upload.fileRequirements', { limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })} + {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}

{ + 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('drops unknown extensions and falls back to default when nothing maps', () => { + expect(extensionsToMimeTypes('dng,xyz')).toEqual(['image/jpeg', 'image/png', 'image/webp']); + }); + }); + + 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 dng)', () => { + expect(extensionsToLabel('jpg,png,dng')).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'); + }); + }); + + 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 ba5514c9..f4881ec4 100644 --- a/frontend/src/utils/fileTypes.ts +++ b/frontend/src/utils/fileTypes.ts @@ -12,6 +12,9 @@ const EXTENSION_TO_MIME: Record = { 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', }; const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp'; @@ -46,3 +49,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(', '); +} From 8e0005e170ce186550906f25c5977ff14ab1861d Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:45:29 +0200 Subject: [PATCH 4/6] chore(main): release 3.90.2-beta.0 (#826) --- .release-please-manifest-beta.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ backend/package.json | 2 +- frontend/package.json | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 0f65f8a0..34785653 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.90.1-beta.0" + ".": "3.90.2-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0f7ecf..63997c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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.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/package.json b/backend/package.json index 1eea21c5..abb783a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.90.1-beta.0", + "version": "3.90.2-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { diff --git a/frontend/package.json b/frontend/package.json index 52158a3e..82e330e0 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.90.2-beta.0", "type": "module", "scripts": { "dev": "vite", From c9b64d9c1a8744c9ee5e068366a500ae0dab36bc Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:06:40 +0200 Subject: [PATCH 5/6] fix(uploads): register HEIC/HEIF with the file validator + fix admin format hint (codex review of #832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the Codex review: - validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither image/heic nor image/heif — so HEIC was rejected before sharp ever saw it, despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp' (offset 4) magic number (the check is .every, so alternatives can't be separate entries). - Changing the shared upload.fileRequirements string to interpolate {{formats}} left the admin PhotoUpload caller passing only { limit }, rendering the placeholder literally (it was also already dropping {{sizeLimit}} from #823). The admin caller now passes formats + sizeLimit + limit, from the admin settings it already loads. --- backend/src/utils/fileSecurityUtils.js | 16 ++++++++++++++++ frontend/src/components/admin/PhotoUpload.tsx | 13 +++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index 39e59b70..28155b33 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -79,6 +79,22 @@ const ALLOWED_IMAGE_TYPES = { 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" + ] } }; 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 })}

Date: Fri, 17 Jul 2026 22:21:43 +0200 Subject: [PATCH 6/6] fix(gallery): serve JPEG preview for non-displayable originals in lightbox (codex review of #832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lightbox falls back to photo.url (the ORIGINAL) when preview_url is null, which happens by default (lightbox_preview_enabled=false). For HEIC/HEIF/DNG the original bytes aren't renderable in an , so the lightbox showed a broken image. Now force preview_url for those formats (by MIME or extension) regardless of the toggle, so the browser always gets the generated JPEG preview. Covers DNG too (forward-compatible with #833). EXPERIMENTAL caveat unchanged: whether the preview actually renders still depends on the backend decoding the source — HEVC-in-HEIC on the prod Alpine image is unverified, DNG needs exiftool (#833). Documented on the PR. --- backend/src/routes/gallery.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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}`