From e8dad4b40ddb816cc2f9a94be456f793adce20d7 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:36:03 +0200 Subject: [PATCH] feat(slideshow): guest-scannable share-link QR overlay (#848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(slideshow): guest-scannable share-link QR overlay (#837) - Global settings (Settings → Slideshow): slideshow_qr_enabled/position/ opacity/size — same option shape and cascade as the watermark. - Per-event tri-state show_qr (migration 163): NULL inherits the global, true/false force on/off; editable in the per-event slideshow card. - State endpoint ships the QR as a PNG data URI (cached per share URL — the 3s projector poll never re-encodes), so the kiosk needs no QR lib and no extra authenticated request. - Kiosk renders the QR in a white padded corner box so it stays scannable on any photo. - i18n: en + de (the slideshow namespace has no other locales yet). * fix(slideshow): persist per-event QR override, show QR on empty shows, bound the QR cache (codex review of #848) - OverviewTab never passed event.show_qr into the settings card (and the Event type lacked the field), so a stored true/false override always displayed as 'inherit' and the next save silently reset it to NULL. - The QR overlay was nested inside the photos.length > 0 branch — an empty or category-filtered live gallery showed only 'Waiting for photos', exactly when 'scan to add the first photos' matters most. Now rendered for any running show. - slideshowQrCache: insertion-order eviction at 50 entries — rotated tokens and past events no longer accumulate base64 PNGs forever. * fix(slideshow): derive the QR origin from the kiosk request when the base is loopback (codex review of #848, round 2) With the compose-default FRONTEND_URL=http://localhost:3000 (or no base configured) the overlay QR sent scanning phones to their own localhost. The state poll comes from the kiosk browser itself, so its Host header + protocol (trust proxy is configured) are exactly the public origin guests can reach — used whenever the configured base is missing or loopback. Mirrors the ?origin= fallback #847 uses for the admin-side QR downloads. * fix(slideshow): kiosk passes its origin for the QR fallback (codex review of #848, round 3) req.get('host') is not the browser origin behind the standard proxies — frontend/nginx.conf forwards $host with the port stripped, so a compose LAN deployment on :3000 encoded port 80. The kiosk now sends window.location.origin with the session/state calls (validated server-side, same pattern as #847's admin downloads); the Host-derived origin remains as second fallback. * fix(slideshow): reject loopback kiosk origins, throttle QR regeneration per event (codex review of #848, confirmation round) - A loopback window.location.origin from the kiosk is no more guest-reachable than the loopback base it would replace — rejected; when no reachable URL remains the overlay is suppressed entirely (no QR beats a QR that sends phones to their own localhost). New test pins the suppression. - The QR cache is keyed by event id with a 60s regeneration throttle: the origin is caller-influenced when the base is loopback, so URL-keyed caching let a slideshow-link holder force a fresh QRCode.toDataURL per request via unique origins — a cheap CPU exhaustion path. Encode rate is now bounded per event regardless of input. QR margin also raised to the 4-module spec quiet zone, matching #847. * fix(slideshow): never serve a mismatched cached QR + single-flight encoding (codex review of #848, final round) - A slideshow-token holder could poison the projector's QR: an attacker-origin entry cached per event was served to the legitimate kiosk for the rest of the throttle window. A cached artifact is now only served when its URL matches the request; mismatches inside the window suppress the overlay briefly instead of showing foreign content. - Cold-cache stampede closed: concurrent polls share one in-flight encode promise instead of each scheduling a 512px render. Rejected from the same round (false positive, verified empirically): the loopback regex claim — /^https?:\/\/(localhost|127\.)/ matches 'http://localhost:3000' and '127.0.0.1:port' just fine (no trailing slash required), and the suppression test runs green. --- .../__tests__/routes/slideshowPublic.test.js | 59 +++++++++- .../migrations/core/163_add_event_show_qr.js | 22 ++++ backend/src/routes/adminEvents/slideshow.js | 8 ++ backend/src/routes/adminSettings.js | 15 +++ backend/src/routes/gallery.js | 110 +++++++++++++++++- backend/src/utils/slideshowGlobals.js | 10 ++ .../admin/SlideshowGlobalDefaultsCard.tsx | 71 +++++++++++ .../admin/SlideshowSettingsCard.tsx | 3 + .../components/admin/SlideshowStyleFields.tsx | 19 +++ frontend/src/i18n/locales/de.json | 3 + frontend/src/i18n/locales/en.json | 3 + .../pages/admin/event-details/OverviewTab.tsx | 1 + frontend/src/pages/gallery/SlideshowPage.tsx | 28 +++++ frontend/src/services/events.service.ts | 1 + frontend/src/services/slideshow.service.ts | 30 ++++- frontend/src/types/index.ts | 2 + 16 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/core/163_add_event_show_qr.js diff --git a/backend/__tests__/routes/slideshowPublic.test.js b/backend/__tests__/routes/slideshowPublic.test.js index 5bb6633a..5a793922 100644 --- a/backend/__tests__/routes/slideshowPublic.test.js +++ b/backend/__tests__/routes/slideshowPublic.test.js @@ -99,7 +99,12 @@ describe('public Live Slideshow routes', () => { await setFlag(db, 'slideshow', true); }); - const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`; + // QR overlay: supertest's Host is loopback, and a loopback base is now + // suppressed rather than encoded — the kiosk passes its reachable + // window.location.origin, so the QR tests do the same. + const KIOSK_ORIGIN = 'https://gallery.example.com'; + const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state?origin=${encodeURIComponent(KIOSK_ORIGIN)}`; + const stateUrlNoOrigin = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`; describe('resolveSlideshow guards', () => { it('200 + per-event display settings on a live link', async () => { @@ -228,6 +233,58 @@ describe('public Live Slideshow routes', () => { }); }); + describe('slideshowSettings — QR overlay cascade (#837)', () => { + async function enableGlobalQr() { + await setSetting(db, 'slideshow_qr_enabled', true); + await setSetting(db, 'slideshow_qr_position', 'top-right'); + await setSetting(db, 'slideshow_qr_opacity', 80); + await setSetting(db, 'slideshow_qr_size', 18); + } + + it('inherits the global QR overlay when show_qr is NULL', async () => { + await insertEvent(db, { show_qr: null }); + await enableGlobalQr(); + const res = await request(app).get(stateUrl()); + expect(res.body.qr).toMatchObject({ + position: 'top-right', + opacity: 80, + size: 18, + }); + // Share-link QR ships as a PNG data URI — no client QR lib needed. + expect(res.body.qr.data_url).toMatch(/^data:image\/png;base64,/); + }); + + it('is null by default (global off, no override)', async () => { + await insertEvent(db, { show_qr: null }); + const res = await request(app).get(stateUrl()); + expect(res.body.qr).toBeNull(); + }); + + it('per-event OFF override hides the QR even when the global is on', async () => { + await insertEvent(db, { show_qr: 0 }); + await enableGlobalQr(); + const res = await request(app).get(stateUrl()); + expect(res.body.qr).toBeNull(); + }); + + it('per-event ON override shows the QR even when the global is off', async () => { + await insertEvent(db, { show_qr: 1 }); + const res = await request(app).get(stateUrl()); + expect(res.body.qr).not.toBeNull(); + expect(res.body.qr.data_url).toMatch(/^data:image\/png;base64,/); + // Look falls back to the global defaults. + expect(res.body.qr.position).toBe('bottom-left'); + }); + + it('suppresses the QR when no guest-reachable origin exists (loopback base, no kiosk origin)', async () => { + await insertEvent(db, { show_qr: 1 }); + const res = await request(app).get(stateUrlNoOrigin()); + // Encoding localhost would send scanning phones to THEIR localhost — + // no QR beats a broken QR (codex review of #848, confirmation round). + expect(res.body.qr).toBeNull(); + }); + }); + describe('display-only token guards (#646 review concern 1)', () => { // Mint a real slideshow JWT, then prove it is denied on the // download / upload / feedback routes (display-only contract). diff --git a/backend/migrations/core/163_add_event_show_qr.js b/backend/migrations/core/163_add_event_show_qr.js new file mode 100644 index 00000000..06f03bea --- /dev/null +++ b/backend/migrations/core/163_add_event_show_qr.js @@ -0,0 +1,22 @@ +/** + * #837 — per-event override for the live-slideshow QR overlay. + * Mirrors show_watermark: NULL = inherit the global slideshow_qr_enabled + * setting, true/false force the overlay on/off for this event. + */ +exports.up = async function up(knex) { + const has = await knex.schema.hasColumn('events', 'show_qr'); + if (!has) { + await knex.schema.alterTable('events', (t) => { + t.boolean('show_qr').nullable().defaultTo(null); + }); + } +}; + +exports.down = async function down(knex) { + const has = await knex.schema.hasColumn('events', 'show_qr'); + if (has) { + await knex.schema.alterTable('events', (t) => { + t.dropColumn('show_qr'); + }); + } +}; diff --git a/backend/src/routes/adminEvents/slideshow.js b/backend/src/routes/adminEvents/slideshow.js index d58ad206..58eb1b7f 100644 --- a/backend/src/routes/adminEvents/slideshow.js +++ b/backend/src/routes/adminEvents/slideshow.js @@ -105,6 +105,7 @@ module.exports = (router) => { body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), body('show_watermark').optional({ nullable: true }), + body('show_qr').optional({ nullable: true }), body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS), body('show_order').optional().isIn(SLIDESHOW_ORDERS), body('show_category_id').optional({ nullable: true }).isInt({ min: 1 }) @@ -131,6 +132,12 @@ module.exports = (router) => { ? null : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); } + // QR overlay (#837) — same tri-state semantics as show_watermark. + if (req.body.show_qr !== undefined) { + updates.show_qr = req.body.show_qr === null + ? null + : formatBoolean(parseBooleanInput(req.body.show_qr, false)); + } if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; if (req.body.show_order !== undefined) updates.show_order = req.body.show_order; // Category filter (#202). null clears it (all photos). A non-null id must @@ -160,6 +167,7 @@ module.exports = (router) => { show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, show_watermark: updates.show_watermark ?? event.show_watermark ?? null, + show_qr: 'show_qr' in updates ? updates.show_qr : (event.show_qr ?? null), show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none', show_order: updates.show_order ?? event.show_order ?? 'chronological', show_category_id: 'show_category_id' in updates ? updates.show_category_id : (event.show_category_id ?? null) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 5183a6bb..fc5981fc 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -383,6 +383,21 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r const n = Math.min(40, Math.max(3, Math.round(Number(req.body.slideshow_watermark_size) || 12))); push('slideshow_watermark_size', n); } + // QR overlay (#837) — same option shape as the watermark. + if (has('slideshow_qr_enabled')) push('slideshow_qr_enabled', !!req.body.slideshow_qr_enabled); + if (has('slideshow_qr_position')) { + const allowed = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + const v = allowed.includes(req.body.slideshow_qr_position) ? req.body.slideshow_qr_position : 'bottom-left'; + push('slideshow_qr_position', v); + } + if (has('slideshow_qr_opacity')) { + const n = Math.min(100, Math.max(0, Math.round(Number(req.body.slideshow_qr_opacity) || 0))); + push('slideshow_qr_opacity', n); + } + if (has('slideshow_qr_size')) { + const n = Math.min(40, Math.max(5, Math.round(Number(req.body.slideshow_qr_size) || 14))); + push('slideshow_qr_size', n); + } for (const u of updates) { await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 07fce2fe..8e17607f 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -305,7 +305,7 @@ async function resolveSlideshow(slug, token) { // watermark (a white, semi-transparent corner logo). The logo URL is resolved // from the chosen source so the kiosk renders it without knowing about // branding/event internals; null url = nothing to overlay. -async function slideshowSettings(event) { +async function slideshowSettings(event, req) { // 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). @@ -342,6 +342,26 @@ async function slideshowSettings(event) { }; } } + // QR overlay (#837): like the watermark, the LOOK is global-only and the + // per-event `show_qr` tri-state (NULL = inherit) decides visibility. The QR + // encodes the gallery share URL and ships as a data URI so the public + // slideshow client needs no QR library and no extra authenticated endpoint. + const qrOverride = event.show_qr; + const qrInherit = (qrOverride === null || qrOverride === undefined); + const qrEnabled = qrInherit ? g.qr_enabled : (qrOverride === true || qrOverride === 1 || qrOverride === '1'); + let qr = null; + if (qrEnabled) { + const dataUrl = await slideshowQrDataUrl(event, req); + if (dataUrl) { + qr = { + data_url: dataUrl, + position: g.qr_position, + opacity: g.qr_opacity, + size: g.qr_size, + }; + } + } + return { interval_ms: event.show_interval_ms || 5000, transition: event.show_transition || 'crossfade', @@ -352,9 +372,93 @@ async function slideshowSettings(event) { order: event.show_order || 'chronological', fit: g.fit, watermark, + qr, }; } +// The state endpoint is polled every ~3s per projector — cache the generated +// QR data URI per share URL instead of re-encoding on every poll. Bounded: +// entries live for past events / rotated tokens too, so without eviction the +// map would grow with every share URL ever displayed (codex review of #848). +// Insertion-order eviction is enough — concurrently-shown events stay hot. +const SLIDESHOW_QR_CACHE_MAX = 50; +// Keyed by event id (NOT by URL): the origin is caller-influenced when the +// configured base is loopback, so URL-keyed caching would let a slideshow +// -link holder force a fresh QRCode.toDataURL per request with unique +// origins — a cheap CPU-exhaustion path (codex review of #848, +// confirmation round). Per-event entries + a regeneration throttle bound +// the encode rate regardless of what the caller sends. +const SLIDESHOW_QR_REGEN_MS = 60_000; +const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at } +// Localhost/relative guard (codex review of #848): with the compose-default +// FRONTEND_URL=http://localhost:3000 (or none configured) the QR would send +// scanning phones to THEIR localhost. The state poll comes from the kiosk +// browser itself, so its Host header + protocol are exactly the public +// origin guests can reach — prefer that whenever the configured base is +// missing or loopback. trust proxy is configured, so req.protocol respects +// X-Forwarded-Proto behind the standard reverse-proxy setups. +const QR_LOCAL_BASE_RE = /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/i; +const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i; +async function slideshowQrDataUrl(event, req) { + try { + const shareToken = getEventShareToken(event); + if (!shareToken) return null; + let { shareUrl, sharePath } = await buildShareLinkVariants({ slug: event.slug, shareToken }); + if (!/^https?:\/\//i.test(shareUrl) || QR_LOCAL_BASE_RE.test(shareUrl)) { + // Prefer the kiosk's own window.location.origin (?origin=, validated): + // req.get('host') is NOT the browser origin behind the standard + // proxies — frontend/nginx.conf forwards $host (port stripped), so a + // compose LAN deployment on :3000 would encode port 80. A LOOPBACK + // kiosk origin is rejected too: it is no more guest-reachable than + // the loopback base it would replace (codex review of #848). + const rawOrigin = req?.query?.origin; + const queryOrigin = typeof rawOrigin === 'string' && QR_ORIGIN_RE.test(rawOrigin) && !QR_LOCAL_BASE_RE.test(rawOrigin) + ? rawOrigin.replace(/\/$/, '') + : null; + const host = req && req.get ? req.get('host') : null; + const hostOrigin = host ? `${req.protocol}://${host}` : null; + if (queryOrigin) shareUrl = `${queryOrigin}${sharePath}`; + else if (hostOrigin && !QR_LOCAL_BASE_RE.test(hostOrigin)) shareUrl = `${hostOrigin}${sharePath}`; + // Still loopback/relative → no reachable URL exists; suppress the + // overlay rather than encode a QR that sends phones to localhost. + else return null; + } + + const cached = slideshowQrCache.get(event.id); + if (cached && cached.url === shareUrl) return cached.dataUrl; + // URL differs from the cached one: NEVER serve the mismatched artifact — + // a slideshow-token holder could otherwise poison the projector's QR + // with an attacker origin for a whole throttle window (codex review of + // #848, final round). Inside the window the overlay is briefly + // suppressed instead; regeneration stays bounded per event. + if (cached && Date.now() - cached.at < SLIDESHOW_QR_REGEN_MS) { + return cached.pending ? cached.dataUrl : null; + } + // Single-flight: concurrent polls on a cold cache must not each + // schedule their own 512px encode — reserve the entry with a shared + // promise before awaiting. + if (cached && cached.pending && cached.url === shareUrl) return cached.pending; + const QRCode = require('qrcode'); + const entry = { url: shareUrl, dataUrl: null, at: Date.now(), pending: null }; + entry.pending = QRCode.toDataURL(shareUrl, { width: 512, margin: 4 }).then((dataUrl) => { + entry.dataUrl = dataUrl; + entry.pending = null; + return dataUrl; + }).catch((e) => { + slideshowQrCache.delete(event.id); + throw e; + }); + if (!slideshowQrCache.has(event.id) && slideshowQrCache.size >= SLIDESHOW_QR_CACHE_MAX) { + slideshowQrCache.delete(slideshowQrCache.keys().next().value); + } + slideshowQrCache.set(event.id, entry); + return await entry.pending; + } catch (e) { + logger.error('Slideshow QR generation failed:', e); + return null; + } +} + // Open a slideshow session: validate the token and mint a short-lived gallery // JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo / // image endpoints → visible photos only, no client-only/hidden). The page @@ -391,7 +495,7 @@ router.get('/:slug/show/:token/session', handleAsync(async (req, res) => { event_type: event.event_type, color_theme: event.color_theme }, - settings: await slideshowSettings(event), + settings: await slideshowSettings(event, req), photo_count: parseInt(count, 10) || 0, expires_at: event.expires_at || null }); @@ -411,7 +515,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => { const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count'); res.json({ - ...(await slideshowSettings(event)), + ...(await slideshowSettings(event, req)), photo_count: parseInt(count, 10) || 0, expires_at: event.expires_at || null }); diff --git a/backend/src/utils/slideshowGlobals.js b/backend/src/utils/slideshowGlobals.js index b5b00046..9eb0f358 100644 --- a/backend/src/utils/slideshowGlobals.js +++ b/backend/src/utils/slideshowGlobals.js @@ -21,6 +21,7 @@ async function getSlideshowGlobals() { const [ enabled, source, position, opacity, style, size, fit, logo, logoDark, favicon, + qrEnabled, qrPosition, qrOpacity, qrSize, ] = await Promise.all([ getAppSetting('slideshow_watermark_enabled', false), getAppSetting('slideshow_watermark_source', 'logo'), @@ -32,6 +33,11 @@ async function getSlideshowGlobals() { getAppSetting('branding_logo_url', null), getAppSetting('branding_logo_url_dark', null), getAppSetting('branding_favicon_url', null), + // QR overlay (#837) — guests scan the gallery link straight off the beamer. + getAppSetting('slideshow_qr_enabled', false), + getAppSetting('slideshow_qr_position', 'bottom-left'), + getAppSetting('slideshow_qr_opacity', 90), + getAppSetting('slideshow_qr_size', 14), ]); const val = { @@ -45,6 +51,10 @@ async function getSlideshowGlobals() { branding_logo_url: logo || null, branding_logo_url_dark: logoDark || null, branding_favicon_url: favicon || null, + qr_enabled: qrEnabled === true, + qr_position: qrPosition || 'bottom-left', + qr_opacity: qrOpacity ?? 90, + qr_size: qrSize ?? 14, }; cache = { at: now, val }; return val; diff --git a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx index ccf6f19e..51fb8425 100644 --- a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx +++ b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx @@ -38,6 +38,10 @@ const DEFAULTS: SlideshowGlobalDefaults = { slideshow_watermark_opacity: 60, slideshow_watermark_style: 'white', slideshow_watermark_size: 12, + slideshow_qr_enabled: false, + slideshow_qr_position: 'bottom-left', + slideshow_qr_opacity: 90, + slideshow_qr_size: 14, }; const inputClass = @@ -65,6 +69,10 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => { slideshow_watermark_opacity: s.slideshow_watermark_opacity ?? DEFAULTS.slideshow_watermark_opacity, slideshow_watermark_style: s.slideshow_watermark_style ?? DEFAULTS.slideshow_watermark_style, slideshow_watermark_size: s.slideshow_watermark_size ?? DEFAULTS.slideshow_watermark_size, + slideshow_qr_enabled: s.slideshow_qr_enabled ?? DEFAULTS.slideshow_qr_enabled, + slideshow_qr_position: s.slideshow_qr_position ?? DEFAULTS.slideshow_qr_position, + slideshow_qr_opacity: s.slideshow_qr_opacity ?? DEFAULTS.slideshow_qr_opacity, + slideshow_qr_size: s.slideshow_qr_size ?? DEFAULTS.slideshow_qr_size, }); }).catch(() => { /* keep defaults */ }); return () => { cancelled = true; }; @@ -256,6 +264,69 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => { )} + {/* Share-link QR overlay (#837) */} +
+ {t('slideshow.qrModeHint', 'Position, size and opacity are configured under Settings → Slideshow.')} +
+