* 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.