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
- {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}`