From 2df6f8e55c985a06f6ef1fac56139c24d44fdd28 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 2 Nov 2026 09:31:58 -0500 Subject: [PATCH] fix(ui): replace CSP-blocked inline handlers with delegated listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hash-allow-list CSP refuses inline onclick=/onerror= attributes, so three TS islands shipped dead controls: - stats.ts rendered Most-Read/Longest covers with onerror= — every /stats load logged violations (5+ on the dev data) and the broken cover fallback never ran. Now: capture-phase error listeners on the three list containers (they persist across innerHTML swaps; error does not bubble but capture still reaches them). - custom-section-builder.ts picker rows and selection chips used onclick= against window globals. Rows now carry data-book-id/title/ author and chips data-remove-book; initCustomSectionBuilder wires one delegated click listener per container. window.addBookToSelection/ removeBookFromSelection exports removed — no remaining references. - linking.ts: the unlinked-books 'Link' button called window.showMatchModal, which never existed anywhere (doubly dead: missing global AND CSP-blocked); suggestion rows and Cancel used onclick= too. All now data-attribute driven through one document-level delegated listener; row Link opens the suggestions flow via getSuggestions(progressId), which fetches matches and opens the modal. Verified: tsc --noEmit, vp check, vitest (198), manual modal flows. --- web/src/custom-section-builder.ts | 39 +++++++++++++++++++++---------- web/src/linking.ts | 30 ++++++++++++++++++++---- web/src/stats.ts | 19 +++++++++++++-- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts index faec7a78..8b058e21 100644 --- a/web/src/custom-section-builder.ts +++ b/web/src/custom-section-builder.ts @@ -257,6 +257,27 @@ function initCustomSectionBuilder(): void { if (form) { form.addEventListener("submit", saveCustomSection); } + + // Delegated clicks for the generated picker/selection markup: the CSP + // build refuses inline onclick= handlers, so the rows carry data + // attributes and one listener per container survives innerHTML swaps. + const resultsContainer = document.getElementById("search-results"); + resultsContainer?.addEventListener("click", (e) => { + const row = (e.target as HTMLElement).closest("[data-book-id]"); + if (!row) return; + addBookToSelection( + row.dataset.bookId ?? "", + row.dataset.bookTitle ?? "", + row.dataset.bookAuthor ?? "", + ); + }); + + const selectedContainer = document.getElementById("selected-books"); + selectedContainer?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest("[data-remove-book]"); + if (!btn) return; + removeBookFromSelection(btn.dataset.removeBook ?? ""); + }); } function addFilterRule(): void { @@ -409,7 +430,8 @@ function displaySearchResults(books: BookInfo[]): void { (book) => `
+ data-book-title="${builderEscapeHtml(book.title)}" + data-book-author="${builderEscapeHtml(book.author)}"> ${builderEscapeHtml(book.title)} @@ -464,7 +486,7 @@ function updateSelectedBooksDisplay(): void {
${builderEscapeHtml(book.title)} -
`, @@ -627,13 +649,6 @@ function builderEscapeHtml(text: string): string { initCustomSectionBuilder(); -// Inline onclick handlers in the generated selection HTML resolve against -// globals; bundled module functions need explicit exposure. -declare global { - interface Window { - addBookToSelection: typeof addBookToSelection; - removeBookFromSelection: typeof removeBookFromSelection; - } -} -window.addBookToSelection = addBookToSelection; -window.removeBookFromSelection = removeBookFromSelection; +// Picker/selection clicks are delegated inside initCustomSectionBuilder — +// the CSP build refuses inline onclick= handlers, so the generated markup +// carries data attributes instead and no globals are needed. diff --git a/web/src/linking.ts b/web/src/linking.ts index 56fca4a9..d88a8ff0 100644 --- a/web/src/linking.ts +++ b/web/src/linking.ts @@ -41,7 +41,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {

Confidence: ${Math.round(book.confidence_score * 100)}%

- +
${ @@ -58,7 +58,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {

${match.title}

${match.author} (${Math.round(match.confidence * 100)}%)

- + `, ) @@ -158,7 +158,7 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat (s) => `
+ data-link-book="${progressId}" data-media-id="${s.media_item_id}">

${s.title}

${s.author}

${Math.round(s.confidence * 100)}% match

@@ -167,7 +167,7 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat ) .join("")}
- + `; @@ -181,6 +181,28 @@ function hideMatchModal(): void { } } +// Delegated click wiring for the generated markup above: the CSP build +// refuses inline onclick= handlers (and module functions are not globals). +// The row-level "Link" opens the suggestions flow via getSuggestions — the +// old window.showMatchModal it referenced never existed. +document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const show = target.closest("[data-show-match]"); + if (show) { + void getSuggestions(show.dataset.showMatch ?? ""); + return; + } + const link = target.closest("[data-link-book]"); + if (link) { + void linkBook(link.dataset.linkBook ?? "", link.dataset.mediaId ?? ""); + hideMatchModal(); + return; + } + if (target.closest("[data-hide-match]")) { + hideMatchModal(); + } +}); + Alpine.data("linking", () => ({ autoLinkBooks, getSuggestions, diff --git a/web/src/stats.ts b/web/src/stats.ts index 7e38ce61..bf568bbd 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -510,7 +510,7 @@ function renderTopBooks(books: TopBook[]): void { (book) => ` - + ${escapeHtml(book.title)} @@ -684,7 +684,7 @@ function renderUsageItems(
- + ${escapeHtml(item.title)} @@ -710,6 +710,21 @@ function escapeHtml(value: string): string { } function initStatsPage(): void { + // Broken-cover fallback without inline onerror: the CSP build refuses + // inline handlers, so listen in capture phase on each list container + // (error does not bubble, but capture still reaches it; the containers + // persist across innerHTML swaps). + for (const containerID of ["stats-top-books", "stats-lib-longest", "stats-lib-largest"]) { + document.getElementById(containerID)?.addEventListener( + "error", + (e) => { + const img = e.target as HTMLElement; + if (img instanceof HTMLImageElement) img.style.display = "none"; + }, + true, + ); + } + const params = new URLSearchParams(window.location.search); const initial = params.get("range") || "7"; currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";