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
@@ -10,7 +10,7 @@ templ Admin(user User, stats AdminStats) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="admin" x-init="loadWatchStatus(); checkActiveScans()" class={ "theme-" + user.Theme }>
|
||||
<body x-data="admin" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin")
|
||||
<main class="p-8">
|
||||
<div class="max-w-4xl">
|
||||
|
||||
@@ -246,7 +246,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
:value="scrubbing ? scrubValue : currentTime"
|
||||
class="relative w-full"
|
||||
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
||||
@change="seekTo(scrubValue); scrubbing = false"
|
||||
@change="commitScrub()"
|
||||
aria-label="Seek position"
|
||||
/>
|
||||
<!-- Hover tooltip: time + chapter under the pointer -->
|
||||
@@ -395,7 +395,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<template x-for="secs in skipChoices" :key="'skip' + secs">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSkip(secs); skipOpen = false"
|
||||
@click="pickSkip(secs)"
|
||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||
:class="Number(skipSeconds) === secs ? 'font-semibold' : ''"
|
||||
:style="Number(skipSeconds) === secs ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||
@@ -425,7 +425,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<template x-for="rate in speeds" :key="'speed' + rate">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSpeed(rate); speedOpen = false"
|
||||
@click="pickSpeed(rate)"
|
||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
||||
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||
@@ -437,7 +437,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<button
|
||||
type="button"
|
||||
x-show="!isDefaultSpeed(speed)"
|
||||
@click="setDefaultSpeed(speed); speedOpen = false"
|
||||
@click="pickDefaultSpeed(speed)"
|
||||
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
x-text="'Make ' + speed + '× my default'"
|
||||
@@ -481,7 +481,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<template x-for="opt in sleepChoices" :key="opt.key">
|
||||
<button
|
||||
type="button"
|
||||
@click="chooseSleep(opt); sleepOpen = false"
|
||||
@click="chooseSleepOption(opt)"
|
||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
|
||||
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||
@@ -500,7 +500,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="chooseSleepClock(); sleepOpen = false"
|
||||
@click="chooseSleepClockMode()"
|
||||
class="text-xs px-2.5 py-1 rounded-md font-semibold"
|
||||
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
|
||||
>Arm</button>
|
||||
|
||||
@@ -177,7 +177,7 @@ templ BookShelf(
|
||||
innerHTML templating). Rows are a flat projection of
|
||||
the tree (depth = indent); groups nest via + group. -->
|
||||
<div x-data="filterBuilderExtra" x-cloak
|
||||
x-effect="if (filtersOpen === true) { ensureReadyRule(); }"
|
||||
x-effect="filtersOpen === true && ensureReadyRule()"
|
||||
>
|
||||
<template x-for="row in builderRows" :key="row.key">
|
||||
<div>
|
||||
|
||||
@@ -23,7 +23,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="docs" x-init="scrollSidebarToActive(); initializeSearch(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||
<body x-data="docs" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||
@DocsTopbar(user)
|
||||
<!-- Mobile drawer backdrop -->
|
||||
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')"></div>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -13,7 +13,7 @@ templ Index(loggedIn bool, registrationEnabled bool) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="index" x-init="initIndexTheme(); checkAuthRedirect()">
|
||||
<body x-data="index">
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<select
|
||||
id="theme-select"
|
||||
|
||||
@@ -554,7 +554,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
||||
</div>
|
||||
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
|
||||
<span>Night mode</span>
|
||||
<button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
|
||||
<button @click="toggleFxInvert()" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
|
||||
</div>
|
||||
<div class="reader-tools-row" x-show="isPDF && !treatAsComic">
|
||||
<span>Pointer</span>
|
||||
@@ -593,7 +593,7 @@ templ ReaderTOCDrawer() {
|
||||
<button :class="tocTab === 'contents' ? 'active' : ''" @click="tocTab = 'contents'">
|
||||
Contents <span class="reader-tab-count" x-text="tocItems.length">0</span>
|
||||
</button>
|
||||
<button :class="tocTab === 'pages' ? 'active' : ''" @click="tocTab = 'pages'; initPageThumbs()">
|
||||
<button :class="tocTab === 'pages' ? 'active' : ''" @click="openPageThumbs()">
|
||||
Pages <span class="reader-tab-count" x-text="pageThumbList.length || ''">0</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -691,7 +691,7 @@ templ ReaderAnnotationsDrawer() {
|
||||
x-text="noteCountLabel(newNoteText)"
|
||||
></div>
|
||||
<button
|
||||
@click="addNote(newNoteText); newNoteText = ''"
|
||||
@click="submitNote()"
|
||||
:disabled="noteOverLimit(newNoteText)"
|
||||
class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -820,7 +820,7 @@ templ ReaderSearchDrawer() {
|
||||
/>
|
||||
<button
|
||||
x-show="searchQuery || searchGroups.length"
|
||||
@click="searchQuery = ''; clearSearchResults()"
|
||||
@click="clearSearch()"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 shrink-0"
|
||||
title="Clear results"
|
||||
aria-label="Clear results"
|
||||
@@ -908,7 +908,7 @@ templ ReaderSettingsDrawer() {
|
||||
<template x-for="sw in themeSwatches" :key="sw.value">
|
||||
<button
|
||||
type="button"
|
||||
@click="readingTheme = sw.value; applyTheme()"
|
||||
@click="pickReadingTheme(sw.value)"
|
||||
class="theme-swatch"
|
||||
:class="readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''"
|
||||
:style="'background-color:' + sw.bg + '; color:' + sw.fg"
|
||||
|
||||
Reference in new issue
Block a user