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

+75
View File
@@ -121,7 +121,60 @@ const initializeScanListener = function (this: any) {
export { logout, restoreLibrarySelection };
Alpine.data("header", () => ({
init() {
// Shared panel state: one open panel across all sidebar panels. Default
// to the persisted panel (or the admin section on /admin pages).
try {
const v = localStorage.getItem("sidebar-open-panel");
this.openPanel =
v !== null
? v === ""
? null
: v
: window.location.pathname.startsWith("/admin")
? "admin"
: null;
} catch {
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
}
initializeSearch();
this.initializeTheme();
this.loadWoodPaneling();
this.updateWoodPanelingIndicators();
this.restoreLibrarySelection();
this.initializeScanListener();
// Persist the sidebar's open panel across navigations (was an inline
// x-init $watch on the panel element).
this.$watch("openPanel", (v: unknown) => {
try {
localStorage.setItem("sidebar-open-panel", v === null ? "" : String(v));
} catch {
/* private-mode localStorage failures are non-fatal */
}
});
},
openPanel: null as string | null,
// Shadow copy for the root-level toggleAppearancePanel: the sidebar shell's
// own themeMode (initialized from the server) is the one that matters at
// runtime — the merged Alpine scope reads the nearest declaration.
themeMode: "",
mobileMenuOpen: false,
// Shared panel toggles: invoked from the sidebar shell's scope, so `this`
// is the merged data stack (shell themeMode + root openPanel).
toggleAppearancePanel() {
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
if (this.openPanel === "appearance") {
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
}
},
setThemeMode(mode: string) {
this.themeMode = mode;
this.$nextTick(() => this.revealCurrentTheme(mode));
},
selectTheme(name: string) {
changeTheme(name);
this.openPanel = null;
},
logout,
initializeSearch,
initializeTheme,
@@ -137,3 +190,25 @@ Alpine.data("header", () => ({
scanNewItems: 0,
scanFilesScanned: 0,
}));
// Sidebar shell: owns the appearance panel's theme-mode state. The shared
// openPanel lives on the header root (one open panel across all panels).
// Registered (not inline x-data) so its methods work under the CSP-safe
// Alpine build, which cannot evaluate arbitrary inline sentences.
Alpine.data("sidebarShell", (initialThemeMode: string) => ({
themeMode: initialThemeMode,
toggleAppearancePanel() {
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
if (this.openPanel === "appearance") {
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
}
},
setThemeMode(mode: string) {
this.themeMode = mode;
this.$nextTick(() => this.revealCurrentTheme(mode));
},
selectTheme(name: string) {
changeTheme(name);
this.openPanel = null;
},
}));