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:
1 parent
a730a62280
commit
eb57b7208b
15 files changed
+154
-23
No files matched your search
@@ -596,6 +596,8 @@ function bindDeleteLibraryModalClose(): void {
|
||||
Alpine.data("admin", () => ({
|
||||
init() {
|
||||
bindDeleteLibraryModalClose();
|
||||
this.loadWatchStatus();
|
||||
this.checkActiveScans();
|
||||
},
|
||||
cancelAllScans,
|
||||
checkActiveScans,
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import Alpine from "alpinejs";
|
||||
import Alpine from "@alpinejs/csp";
|
||||
|
||||
// Extend Window interface to include Alpine. The `htmx` member is declared
|
||||
// once in shims.d.ts — redeclaring it here with a different shape is a
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -153,6 +153,11 @@ function initializeCodeCopyButtons(): void {
|
||||
}
|
||||
|
||||
Alpine.data("docs", () => ({
|
||||
init() {
|
||||
this.scrollSidebarToActive();
|
||||
this.initializeSearch();
|
||||
this.initializeCodeCopyButtons();
|
||||
},
|
||||
toggleSidebar,
|
||||
toggleCollapsible,
|
||||
initializeSearch: initializeDocsSearch,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
@@ -44,6 +44,10 @@ async function checkAuthRedirect(): Promise<void> {
|
||||
export { changeTheme, checkAuthRedirect, initIndexTheme };
|
||||
|
||||
Alpine.data("index", () => ({
|
||||
init() {
|
||||
initIndexTheme();
|
||||
checkAuthRedirect();
|
||||
},
|
||||
changeTheme,
|
||||
checkAuthRedirect,
|
||||
initIndexTheme,
|
||||
|
||||
@@ -3587,6 +3587,27 @@ document.addEventListener("alpine:init", () => {
|
||||
this.readingMode = this.readingMode === "dark" ? "light" : "dark";
|
||||
this.applyTheme();
|
||||
},
|
||||
// Popover/toolbar handlers (CSP-safe: single method calls from templ).
|
||||
toggleFxInvert() {
|
||||
this.fxInvert = !this.fxInvert;
|
||||
this.applyFxFilter(true);
|
||||
},
|
||||
openPageThumbs() {
|
||||
this.tocTab = "pages";
|
||||
this.initPageThumbs();
|
||||
},
|
||||
submitNote() {
|
||||
this.addNote(this.newNoteText);
|
||||
this.newNoteText = "";
|
||||
},
|
||||
clearSearch() {
|
||||
this.searchQuery = "";
|
||||
this.clearSearchResults();
|
||||
},
|
||||
pickReadingTheme(value: string) {
|
||||
this.readingTheme = value;
|
||||
this.applyTheme();
|
||||
},
|
||||
detectChromeDarkMode(): boolean {
|
||||
const darkChromeThemes = [
|
||||
"theme-tokyo-night",
|
||||
|
||||
Reference in new issue
Block a user