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

+5 -7
View File
@@ -3,7 +3,6 @@ package templates
templ Header(user User, currentPath string) {
<div
x-data="header"
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
>
<!-- Mobile backdrop -->
<div
@@ -84,8 +83,7 @@ templ Header(user User, currentPath string) {
</nav>
<!-- Bottom: theme picker + user -->
<div
x-data={ "{ openPanel: (function () { try { var v = localStorage.getItem('sidebar-open-panel'); if (v !== null) { return v === '' ? null : v; } } catch (e) {} return window.location.pathname.startsWith('/admin') ? 'admin' : null; })(), themeMode: '" + string(ThemeModeOf(user.Theme)) + "' }" }
x-init="$watch('openPanel', function (v) { try { localStorage.setItem('sidebar-open-panel', v === null ? '' : v); } catch (e) {} })"
x-data={ "sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')" }
class="shrink-0 px-3 py-3 space-y-2 border-t"
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
>
@@ -98,7 +96,7 @@ templ Header(user User, currentPath string) {
<div class="sidebar-panel">
<button
type="button"
@click={ "openPanel = openPanel === 'appearance' ? null : 'appearance'; if (openPanel === 'appearance') $nextTick(() => revealCurrentTheme(themeMode))" }
@click="toggleAppearancePanel()"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
>
@Icon("palette", "h-5 w-5 shrink-0")
@@ -112,7 +110,7 @@ templ Header(user User, currentPath string) {
<div class="grid grid-cols-2 gap-1 px-2 pt-1">
<button
type="button"
@click="themeMode = 'dark'; $nextTick(() => revealCurrentTheme('dark'))"
@click="setThemeMode('dark')"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
>
@@ -122,7 +120,7 @@ templ Header(user User, currentPath string) {
</button>
<button
type="button"
@click="themeMode = 'light'; $nextTick(() => revealCurrentTheme('light'))"
@click="setThemeMode('light')"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
>
@@ -136,7 +134,7 @@ templ Header(user User, currentPath string) {
for _, opt := range ThemeOptions {
<button
type="button"
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" }
@click="selectTheme(opt.Name)"
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
x-show={ "themeMode === '" + string(opt.Mode) + "'" }
data-theme={ opt.Name }