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/__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/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/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/uploadSettings.js b/backend/src/services/uploadSettings.js
index 7a9944f7..13062298 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',
// 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
diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js
index b2090f22..5eec0f0f 100644
--- a/backend/src/utils/fileSecurityUtils.js
+++ b/backend/src/utils/fileSecurityUtils.js
@@ -80,6 +80,22 @@ const ALLOWED_IMAGE_TYPES = {
// 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)
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",
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]
);
+ // #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