feat(web): switch to the Alpine CSP build; drop 'unsafe-eval' from CSP

Standard Alpine evaluates every attribute expression through
new Function(), which a strict Content-Security-Policy cannot allow.
Swap to the official CSP-safe build (@alpinejs/csp), whose parser
evaluates expressions against registered component scope without
eval. script-src now carries only 'self' and 'unsafe-inline' (the
latter still required by the pre-paint theme flash guard).

Bindings rewritten out of the unsupported expression shapes:
- 21 multi-statement sequences across admin, audio_reader, docs,
  header, index, and reader became single component methods
  (init/loadWatchStatus hooks, commitScrub, pickSkip, pickSpeed,
  pickDefaultSpeed, chooseSleepOption, chooseSleepClockMode,
  toggleFxInvert, openPageThumbs, submitNote, clearSearch,
  pickReadingTheme)
- the header sidebar's inline x-data (openPanel/themeMode IIFE)
  became a registered sidebarShell component; openPanel itself moved
  to the header root so every sidebar panel shares one open state
- the appearance-panel  moved into the component init()
- header init() now also seeds openPanel from localStorage (or the
  admin section on /admin pages)

Alpine magics (, , , ) remain available —
the CSP evaluator injects them into scope.

The Playwright suite covers the converted surfaces end to end.
This commit is contained in:
John O'Keefe committed 2026-10-25 16:58:28 -04:00
1 parent a730a62280
commit eb57b7208b
15 files changed
+154 -23

No files matched your search

+25
View File
@@ -678,6 +678,31 @@ Alpine.data("audioReader", () => ({
})
.catch(() => showToast("Default speed not saved", "error"));
},
// Popover option handlers (CSP-safe: single method calls from templ).
commitScrub() {
this.seekTo(this.scrubValue);
this.scrubbing = false;
},
pickSkip(seconds: number) {
this.setSkip(seconds);
this.skipOpen = false;
},
pickSpeed(rate: number) {
this.setSpeed(rate);
this.speedOpen = false;
},
pickDefaultSpeed(rate: number) {
this.setDefaultSpeed(rate);
this.speedOpen = false;
},
chooseSleepOption(choice: SleepChoice) {
this.chooseSleep(choice);
this.sleepOpen = false;
},
chooseSleepClockMode() {
this.chooseSleepClock();
this.sleepOpen = false;
},
isDefaultSpeed(rate: number): boolean {
return Number(this.defaultSpeed) === rate;