diff --git a/backend/__tests__/routes/slideshowPublic.test.js b/backend/__tests__/routes/slideshowPublic.test.js index 1d3e8666..64e1d185 100644 --- a/backend/__tests__/routes/slideshowPublic.test.js +++ b/backend/__tests__/routes/slideshowPublic.test.js @@ -25,6 +25,7 @@ const cookieParser = require('cookie-parser'); const request = require('supertest'); const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag'); +const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals'); const SLUG = 'wedding-test'; const TOKEN = 'show-tok-abcdef'; @@ -89,6 +90,7 @@ describe('public Live Slideshow routes', () => { await db('app_settings').del(); await db('feature_flags').del(); invalidateFeatureFlagCache(); + invalidateSlideshowGlobals(); await setFlag(db, 'slideshow', true); }); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 06d9213c..1eadc09d 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -345,6 +345,9 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r for (const u of updates) { await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type); } + // Drop the slideshow-globals cache so a running projector picks up the + // change on its next poll rather than after the 5s TTL. + require('../utils/slideshowGlobals').invalidateSlideshowGlobals(); res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) }); } catch (error) { console.error('Slideshow settings save error:', error); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index a678ec22..888e8d70 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -28,7 +28,7 @@ const { getStorage } = require('../services/storage'); const { setGalleryAuthCookies } = require('../utils/tokenUtils'); // Read globals from app_settings (the real table) — settingsService.getSetting // queries a non-existent `settings` table and throws. -const { getAppSetting } = require('../utils/appSettings'); +const { getSlideshowGlobals } = require('../utils/slideshowGlobals'); const { isFeatureEnabled } = require('../middleware/requireFeatureFlag'); const fs = require('fs'); @@ -269,48 +269,48 @@ async function resolveSlideshow(slug, token) { // from the chosen source so the kiosk renders it without knowing about // branding/event internals; null url = nothing to overlay. async function slideshowSettings(event) { + // The global look/fit (Settings → Slideshow) + branding logo URLs come from a + // short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings + // reads each time (PR #646 review, concern 2). + const g = await getSlideshowGlobals(); + // Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE - // globally (Settings → Slideshow); it is NOT duplicated per event. The only - // per-event control is whether the watermark shows: `show_watermark` NULL - // inherits the global enabled flag, true/false force it on/off. + // globally; it is NOT duplicated per event. The only per-event control is + // whether the watermark shows: `show_watermark` NULL inherits the global + // enabled flag, true/false force it on/off. const wm = event.show_watermark; const inherit = (wm === null || wm === undefined); - const enabled = inherit - ? (await getAppSetting('slideshow_watermark_enabled', false)) === true - : (wm === true || wm === 1 || wm === '1'); + const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1'); let watermark = null; if (enabled) { - const source = await getAppSetting('slideshow_watermark_source', 'logo'); - const position = await getAppSetting('slideshow_watermark_position', 'bottom-right'); - const opacity = await getAppSetting('slideshow_watermark_opacity', 60); - const style = await getAppSetting('slideshow_watermark_style', 'white'); - const size = await getAppSetting('slideshow_watermark_size', 12); // Resolve the chosen logo to a URL. Branding assets come from settings; // the event source uses the event's own hero logo. let url; - if (source === 'event') { + if (g.watermark_source === 'event') { url = event.hero_logo_url || null; - } else if (source === 'logo_dark') { - url = await getAppSetting('branding_logo_url_dark', null); - } else if (source === 'favicon') { - url = await getAppSetting('branding_favicon_url', null); + } else if (g.watermark_source === 'logo_dark') { + url = g.branding_logo_url_dark; + } else if (g.watermark_source === 'favicon') { + url = g.branding_favicon_url; } else { - url = await getAppSetting('branding_logo_url', null); + url = g.branding_logo_url; } if (url) { - watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white', size: size ?? 12 }; + watermark = { + url, + position: g.watermark_position, + opacity: g.watermark_opacity, + style: g.watermark_style, + size: g.watermark_size, + }; } } - // Image fit is a global setting (Settings → Slideshow): 'cover' fills + crops, - // 'contain' shows the whole image with black bars (no crop). - const fitRaw = await getAppSetting('slideshow_fit', 'cover'); - const fit = fitRaw === 'contain' ? 'contain' : 'cover'; return { interval_ms: event.show_interval_ms || 5000, transition: event.show_transition || 'crossfade', transition_ms: event.show_transition_ms || 800, colorfilter: event.show_colorfilter || 'none', - fit, + fit: g.fit, watermark, }; } diff --git a/backend/src/utils/slideshowGlobals.js b/backend/src/utils/slideshowGlobals.js new file mode 100644 index 00000000..b5b00046 --- /dev/null +++ b/backend/src/utils/slideshowGlobals.js @@ -0,0 +1,58 @@ +/** + * Cached read of the global Live-Slideshow settings (Settings → Slideshow) + + * the branding logo URLs the watermark resolves against. + * + * Why: a running projector polls `/show/:token/state` every ~3s, and each poll + * resolved the watermark/fit by firing ~7–10 individual `getAppSetting` reads. + * A leaked link × N tabs amplifies that linearly (PR #646 review, concern 2). + * These globals change only via `PUT /admin/settings/slideshow`, so we cache + * the whole bundle with a short TTL and invalidate on write — admin live-edit + * stays effectively instant, and steady-state polls drop to ~0 settings reads. + */ +const { getAppSetting } = require('./appSettings'); + +const TTL_MS = 5000; +let cache = null; // { at, val } + +async function getSlideshowGlobals() { + const now = Date.now(); + if (cache && now - cache.at < TTL_MS) return cache.val; + + const [ + enabled, source, position, opacity, style, size, fit, + logo, logoDark, favicon, + ] = await Promise.all([ + getAppSetting('slideshow_watermark_enabled', false), + getAppSetting('slideshow_watermark_source', 'logo'), + getAppSetting('slideshow_watermark_position', 'bottom-right'), + getAppSetting('slideshow_watermark_opacity', 60), + getAppSetting('slideshow_watermark_style', 'white'), + getAppSetting('slideshow_watermark_size', 12), + getAppSetting('slideshow_fit', 'cover'), + getAppSetting('branding_logo_url', null), + getAppSetting('branding_logo_url_dark', null), + getAppSetting('branding_favicon_url', null), + ]); + + const val = { + watermark_enabled: enabled === true, + watermark_source: source || 'logo', + watermark_position: position || 'bottom-right', + watermark_opacity: opacity ?? 60, + watermark_style: style || 'white', + watermark_size: size ?? 12, + fit: fit === 'contain' ? 'contain' : 'cover', + branding_logo_url: logo || null, + branding_logo_url_dark: logoDark || null, + branding_favicon_url: favicon || null, + }; + cache = { at: now, val }; + return val; +} + +/** Clear the cache — call after any write to the slideshow_* / branding logo settings. */ +function invalidateSlideshowGlobals() { + cache = null; +} + +module.exports = { getSlideshowGlobals, invalidateSlideshowGlobals }; diff --git a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx index 39a95d27..ccf6f19e 100644 --- a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx +++ b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx @@ -23,6 +23,9 @@ import { } from '../../services/slideshow.service'; import { WatermarkSourcePicker } from './WatermarkSourcePicker'; +// Optimistic form state shown until the GET resolves; mirrors the backend +// defaults so the controls don't flicker on load (backend is the source of +// truth — these are just the pre-fetch placeholder). const DEFAULTS: SlideshowGlobalDefaults = { slideshow_fit: 'cover', slideshow_interval_ms: 5000,