fix(ui): replace CSP-blocked inline handlers with delegated listeners

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.
This commit is contained in:
John O'Keefe committed 2026-11-02 09:31:58 -05:00
1 parent 88adf30aa9
commit 2df6f8e55c
3 files changed
+70 -18

No files matched your search

+27 -12
View File
@@ -257,6 +257,27 @@ function initCustomSectionBuilder(): void {
if (form) { if (form) {
form.addEventListener("submit", saveCustomSection); 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<HTMLElement>("[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<HTMLElement>("[data-remove-book]");
if (!btn) return;
removeBookFromSelection(btn.dataset.removeBook ?? "");
});
} }
function addFilterRule(): void { function addFilterRule(): void {
@@ -409,7 +430,8 @@ function displaySearchResults(books: BookInfo[]): void {
(book) => ` (book) => `
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer" <div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
data-book-id="${book.media_item_id}" data-book-id="${book.media_item_id}"
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')"> data-book-title="${builderEscapeHtml(book.title)}"
data-book-author="${builderEscapeHtml(book.author)}">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" <img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
alt="${builderEscapeHtml(book.title)}" alt="${builderEscapeHtml(book.title)}"
class="w-10 h-15 object-cover rounded"> class="w-10 h-15 object-cover rounded">
@@ -464,7 +486,7 @@ function updateSelectedBooksDisplay(): void {
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm" <div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
style="background-color: var(--accent);"> style="background-color: var(--accent);">
<span>${builderEscapeHtml(book.title)}</span> <span>${builderEscapeHtml(book.title)}</span>
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')" <button type="button" data-remove-book="${book.media_item_id}"
class="hover:opacity-70">×</button> class="hover:opacity-70">×</button>
</div> </div>
`, `,
@@ -627,13 +649,6 @@ function builderEscapeHtml(text: string): string {
initCustomSectionBuilder(); initCustomSectionBuilder();
// Inline onclick handlers in the generated selection HTML resolve against // Picker/selection clicks are delegated inside initCustomSectionBuilder —
// globals; bundled module functions need explicit exposure. // the CSP build refuses inline onclick= handlers, so the generated markup
declare global { // carries data attributes instead and no globals are needed.
interface Window {
addBookToSelection: typeof addBookToSelection;
removeBookFromSelection: typeof removeBookFromSelection;
}
}
window.addBookToSelection = addBookToSelection;
window.removeBookFromSelection = removeBookFromSelection;
+26 -4
View File
@@ -41,7 +41,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p> <p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button> <button data-show-match="${book.progress_id}" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
</div> </div>
</div> </div>
${ ${
@@ -58,7 +58,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p> <p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p> <p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
</div> </div>
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button> <button data-link-book="${book.progress_id}" data-media-id="${match.media_item_id}" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
</div> </div>
`, `,
) )
@@ -158,7 +158,7 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
(s) => ` (s) => `
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50" <div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
style="background-color: var(--bg-primary); border-color: var(--border)" style="background-color: var(--bg-primary); border-color: var(--border)"
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();"> data-link-book="${progressId}" data-media-id="${s.media_item_id}">
<p class="font-medium" style="color: var(--text-primary)">${s.title}</p> <p class="font-medium" style="color: var(--text-primary)">${s.title}</p>
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p> <p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p> <p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
@@ -167,7 +167,7 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
) )
.join("")} .join("")}
</div> </div>
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button> <button data-hide-match class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
</div> </div>
`; `;
@@ -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<HTMLElement>("[data-show-match]");
if (show) {
void getSuggestions(show.dataset.showMatch ?? "");
return;
}
const link = target.closest<HTMLElement>("[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", () => ({ Alpine.data("linking", () => ({
autoLinkBooks, autoLinkBooks,
getSuggestions, getSuggestions,
+17 -2
View File
@@ -510,7 +510,7 @@ function renderTopBooks(books: TopBook[]): void {
(book) => ` (book) => `
<a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover"> <a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover">
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)"> <span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/> <img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
</span> </span>
<span class="flex-1 min-w-0"> <span class="flex-1 min-w-0">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span> <span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span>
@@ -684,7 +684,7 @@ function renderUsageItems(
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover"> <a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)"> <span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/> <img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
</span> </span>
<span class="flex-1 min-w-0"> <span class="flex-1 min-w-0">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span> <span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span>
@@ -710,6 +710,21 @@ function escapeHtml(value: string): string {
} }
function initStatsPage(): void { 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 params = new URLSearchParams(window.location.search);
const initial = params.get("range") || "7"; const initial = params.get("range") || "7";
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7"; currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";