Files
picpeak/backend/src/utils/slideshowGlobals.js
T
Paul Nothaft e8dad4b40d feat(slideshow): guest-scannable share-link QR overlay (#848)
* 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.
2026-07-19 22:36:03 +02:00

69 lines
2.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 ~710 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,
qrEnabled, qrPosition, qrOpacity, qrSize,
] = 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),
// 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 = {
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,
qr_enabled: qrEnabled === true,
qr_position: qrPosition || 'bottom-left',
qr_opacity: qrOpacity ?? 90,
qr_size: qrSize ?? 14,
};
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 };