Merge pull request #734 from PicPeak/fix/event-create-nan-and-bool-render

fix: event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders
This commit is contained in:
Paul Nothaft
2026-07-03 11:46:58 +02:00
committed by GitHub
4 changed files with 81 additions and 7 deletions
@@ -0,0 +1,48 @@
/**
* Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug.
*
* The event-create route seeds show_interval_ms/show_transition_ms from
* app_settings via an int-parse-and-clamp. The old inline guard
* (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself
* for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so
* NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL
* rejects NaN for integer columns ("invalid input syntax for type
* integer: NaN") while SQLite silently stores NULL — so POST
* /api/admin/events 500'd on PG whenever the slideshow settings rows
* were absent (getAppSetting returns its null default).
*/
const { clampIntOrUndefined } = require('../../src/utils/numericHelpers');
describe('clampIntOrUndefined', () => {
it('returns undefined for null (the getAppSetting missing-row default)', () => {
expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined();
});
it('returns undefined for undefined, empty string, and booleans', () => {
expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined();
});
it('returns undefined for non-numeric garbage', () => {
expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined();
});
it('never returns NaN for any of the failure-mode inputs', () => {
for (const v of [null, undefined, '', true, false, 'x', {}, []]) {
const out = clampIntOrUndefined(v, 100, 5000);
expect(Number.isNaN(out)).toBe(false);
}
});
it('parses and clamps valid values', () => {
expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500);
expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500);
expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000);
expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000);
expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500);
});
});
+8 -1
View File
@@ -27,6 +27,7 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { getAppSetting } = require('../utils/appSettings');
const { clampIntOrUndefined } = require('../utils/numericHelpers');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const downloadZipService = require('../services/downloadZipService');
@@ -682,7 +683,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
let slideshowSeed = {};
if (await hasColumnCached('events', 'show_interval_ms')) {
try {
const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined);
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
// producing show_interval_ms=NaN in the INSERT — PG rejects that
// with "invalid input syntax for type integer" while SQLite
// silently stores NULL, so event creation 500'd on PG whenever the
// slideshow app_settings rows were absent.
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
+17 -1
View File
@@ -31,4 +31,20 @@ function ensureNumber(value, fallback = 0) {
return Number.isFinite(n) ? n : fallback;
}
module.exports = { ensureInt, ensureNumber };
/**
* Parse a value as an integer clamped to [min, max]; `undefined` on
* anything that doesn't parse (null, undefined, '', booleans, garbage).
*
* Exists because the inline guard `Number.isFinite(+v) ? parseInt(v)`
* disagrees with itself for null/''/true (`+null` is 0 but
* `parseInt(null)` is NaN), which let NaN through Math.min/Math.max
* and into an INSERT PostgreSQL rejects NaN for integer columns
* while SQLite silently stores NULL, so it only failed on PG.
*/
function clampIntOrUndefined(value, min, max) {
const n = parseInt(value, 10);
if (!Number.isFinite(n)) return undefined;
return Math.min(max, Math.max(min, n));
}
module.exports = { ensureInt, ensureNumber, clampIntOrUndefined };
@@ -1053,7 +1053,8 @@ export const EventDetailsPage: React.FC = () => {
</div>
{/* Draft Banner */}
{event.is_draft && !event.is_archived && (
{/* !! — SQLite returns integer booleans; a bare 0 would render as literal "0" */}
{!!event.is_draft && !event.is_archived && (
<Card className="p-4 mb-6 border-2 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-yellow-600 dark:text-yellow-400" />
@@ -1910,13 +1911,14 @@ export const EventDetailsPage: React.FC = () => {
}`}>
{event.protection_level || 'standard'}
</span>
{event.disable_right_click && (
{/* !! on the next three — SQLite integer booleans render literal "0" when falsy */}
{!!event.disable_right_click && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
<MousePointer className="w-3 h-3 mr-1" />
{t('events.rightClickBlocked', 'Right-click blocked')}
</span>
)}
{event.enable_devtools_protection && (
{!!event.enable_devtools_protection && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
<Monitor className="w-3 h-3 mr-1" />
{t('events.devtoolsDetection', 'DevTools detection')}
@@ -1928,7 +1930,7 @@ export const EventDetailsPage: React.FC = () => {
{t('events.downloadsDisabled', 'Downloads disabled')}
</span>
)}
{event.watermark_downloads && (
{!!event.watermark_downloads && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
<Droplets className="w-3 h-3 mr-1" />
{t('events.watermarked', 'Watermarked')}
@@ -2068,7 +2070,8 @@ export const EventDetailsPage: React.FC = () => {
</div>
</label>
{event?.client_access_enabled && (
{/* !! — SQLite integer boolean; bare 0 renders as literal "0" */}
{!!event?.client_access_enabled && (
<>
{/* Set/Change PIN */}
<div className="flex items-end gap-2">