Code review of the personalization/feeds/PWA/push work surfaced ten confirmed issues, now fixed: - Web Push delivery bypassed the SSRF guard (unguarded default client); now routes through safehttp.NewClient with a hard timeout, and the subscribe handler validates the endpoint URL. - Push unsubscribe deleted by endpoint with no owner check; added RemovePushSubscriptionForUser scoped to the signed-in user. - Byte-slice body/content truncation could split a UTF-8 rune and break the RSS content:encoded XML; added a rune-safe truncateUTF8 helper. - Digest sender could permanently starve a user who hid a high-volume source; step the watermark past a full hidden-source scan window. - Service worker cached personalized HTML navigations into a shared cache (identity leak across PWA users); navigations are now network-only, CACHE_VERSION bumped to v2 to purge stale pages. - Public /api/article leaked discarded/unclassified bodies; filter to classified, non-sentinel stories. - runLocal never started the push sender; digests now fire in -local. - Push client had no timeout, so one hung endpoint stalled all sends. - Reader migration resurrected cross-device-cleared reads; gate it behind a one-time flag so the server stays authoritative. - Bookmarks count didn't match the classified list filter.
489 lines
20 KiB
JavaScript
489 lines
20 KiB
JavaScript
// Feed / reader mode. Presents the stories currently on the page one at a
|
|
// time in a focused overlay, marking each read as it's shown. Left/right arrow
|
|
// keys (or the on-screen buttons) page through them; the full article text is
|
|
// whatever Pete captured at ingest (content:encoded or the scraped body),
|
|
// fetched on demand from /api/article and cached for the session.
|
|
//
|
|
// Read state lives in localStorage under pete.read.v1. It's deliberately
|
|
// device-local — unlike the small prefs blob, the read set grows unbounded, so
|
|
// it isn't mirrored through PetePrefs to the account.
|
|
(function () {
|
|
var overlay = document.getElementById("pete-reader");
|
|
if (!overlay) return;
|
|
|
|
var READ_KEY = "pete.read.v1";
|
|
var articleEl = overlay.querySelector("[data-reader-article]");
|
|
var scrollEl = overlay.querySelector("[data-reader-scroll]");
|
|
var progressEl = overlay.querySelector("[data-reader-progress]");
|
|
var linkEl = overlay.querySelector("[data-reader-link]");
|
|
var prevBtn = overlay.querySelector("[data-reader-prev]");
|
|
var nextBtn = overlay.querySelector("[data-reader-next]");
|
|
var closeBtn = overlay.querySelector("[data-reader-close]");
|
|
var backdrop = overlay.querySelector("[data-reader-backdrop]");
|
|
var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]");
|
|
var relatedEl = overlay.querySelector("[data-reader-related]");
|
|
var relatedCache = {}; // id -> results array
|
|
|
|
// Signed-in users (Authentik) get read + bookmark state synced server-side;
|
|
// window.PETE_USER is non-null for them. Anonymous visitors keep the
|
|
// localStorage-only behaviour and never see the bookmark controls.
|
|
var SIGNED_IN = !!(window.PETE_USER);
|
|
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
|
|
|
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
|
var index = 0;
|
|
var open = false;
|
|
var contentCache = {}; // id -> {content, lede}
|
|
var inflight = null;
|
|
|
|
// ---- read-state store -----------------------------------------------------
|
|
function loadRead() {
|
|
try {
|
|
var raw = localStorage.getItem(READ_KEY);
|
|
var obj = raw ? JSON.parse(raw) : {};
|
|
return obj && typeof obj === "object" ? obj : {};
|
|
} catch (e) { return {}; }
|
|
}
|
|
function saveRead(set) {
|
|
try { localStorage.setItem(READ_KEY, JSON.stringify(set)); } catch (e) {}
|
|
}
|
|
var readSet = loadRead();
|
|
|
|
function isRead(id) { return !!readSet[id]; }
|
|
function setRead(id, on) {
|
|
if (on) readSet[id] = 1; else delete readSet[id];
|
|
saveRead(readSet);
|
|
paintCard(id, on);
|
|
if (SIGNED_IN) postState("/api/read", { id: Number(id), read: !!on });
|
|
}
|
|
|
|
// ---- server sync (signed-in only) -----------------------------------------
|
|
function postState(url, body) {
|
|
try {
|
|
fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
credentials: "same-origin",
|
|
keepalive: true
|
|
}).catch(function () {});
|
|
} catch (e) {}
|
|
}
|
|
|
|
function isBookmarked(id) { return !!bookmarkSet[id]; }
|
|
|
|
// setBookmark updates memory, paints every matching control, and persists.
|
|
function setBookmark(id, on) {
|
|
if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
|
|
paintBookmark(id, on);
|
|
postState("/api/bookmark", { id: Number(id), on: !!on });
|
|
// On the bookmarks page, an un-bookmark should drop the card immediately.
|
|
if (!on && location.pathname === "/bookmarks") {
|
|
document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
|
|
c.parentNode && c.parentNode.removeChild(c);
|
|
});
|
|
}
|
|
}
|
|
|
|
// setBookmarkQuiet applies server-provided state without echoing it back.
|
|
function setBookmarkQuiet(id, on) {
|
|
if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
|
|
paintBookmark(id, on);
|
|
}
|
|
|
|
function paintBookmark(id, on) {
|
|
document.querySelectorAll('[data-bookmark-btn][data-story-id="' + cssEsc(id) + '"]').forEach(function (b) {
|
|
applyCardBookmark(b, on);
|
|
});
|
|
if (readerBookmarkBtn && items[index] && String(items[index].id) === String(id)) {
|
|
applyReaderBookmark(on);
|
|
}
|
|
}
|
|
|
|
function applyCardBookmark(btn, on) {
|
|
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
|
var svg = btn.querySelector("svg");
|
|
if (on) {
|
|
btn.style.background = "var(--accent)";
|
|
btn.style.color = "#1c1305";
|
|
if (svg) svg.setAttribute("fill", "currentColor");
|
|
} else {
|
|
btn.style.background = "rgba(20,14,6,.62)";
|
|
btn.style.color = "#fff";
|
|
if (svg) svg.setAttribute("fill", "none");
|
|
}
|
|
}
|
|
|
|
function applyReaderBookmark(on) {
|
|
if (!readerBookmarkBtn) return;
|
|
readerBookmarkBtn.setAttribute("aria-pressed", on ? "true" : "false");
|
|
readerBookmarkBtn.textContent = on ? "🔖 Saved" : "🔖 Save";
|
|
}
|
|
|
|
// initUserState reveals the bookmark controls and pulls the signed-in user's
|
|
// read + bookmark state for the stories on this page, painting them and
|
|
// migrating any device-local reads the account doesn't have yet.
|
|
function initUserState() {
|
|
if (!SIGNED_IN) return;
|
|
document.querySelectorAll("[data-bookmark-btn]").forEach(function (b) {
|
|
b.style.display = "inline-flex";
|
|
});
|
|
var ids = [];
|
|
document.querySelectorAll("[data-story-card]").forEach(function (c) {
|
|
var id = c.getAttribute("data-id");
|
|
if (id) ids.push(id);
|
|
});
|
|
if (!ids.length) return;
|
|
fetch("/api/state?ids=" + encodeURIComponent(ids.join(",")), { credentials: "same-origin" })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (data) {
|
|
if (!data) return;
|
|
var serverRead = Object.create(null);
|
|
(data.read || []).forEach(function (id) {
|
|
serverRead[id] = 1; readSet[id] = 1; paintCard(id, true);
|
|
});
|
|
(data.bookmarked || []).forEach(function (id) { setBookmarkQuiet(id, true); });
|
|
// Migrate device-local reads the account doesn't have yet — but only
|
|
// once per device. After the first sync the server is authoritative, so
|
|
// a story the user later marks unread on another device stays unread
|
|
// instead of being perpetually resurrected from this device's stale
|
|
// local set on every page load.
|
|
var MIGRATED_KEY = "pete.readMigrated.v1";
|
|
var migrated = false;
|
|
try { migrated = localStorage.getItem(MIGRATED_KEY) === "1"; } catch (e) {}
|
|
if (!migrated) {
|
|
ids.forEach(function (id) {
|
|
if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true });
|
|
});
|
|
try { localStorage.setItem(MIGRATED_KEY, "1"); } catch (e) {}
|
|
}
|
|
saveRead(readSet);
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
// Reflect read state onto every matching card on the page (a story can appear
|
|
// in more than one section on the home page).
|
|
function paintCard(id, on) {
|
|
document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
|
|
if (on) c.setAttribute("data-read", "1");
|
|
else c.removeAttribute("data-read");
|
|
});
|
|
}
|
|
function cssEsc(s) {
|
|
return String(s).replace(/["\\]/g, "\\$&");
|
|
}
|
|
|
|
// ---- collecting the page's stories ----------------------------------------
|
|
function isVisible(el) {
|
|
return window.getComputedStyle(el).display !== "none";
|
|
}
|
|
function collect() {
|
|
var seen = Object.create(null);
|
|
var out = [];
|
|
document.querySelectorAll("[data-story-card]").forEach(function (c) {
|
|
var id = c.getAttribute("data-id");
|
|
if (!id || seen[id] || !isVisible(c)) return;
|
|
var url = c.getAttribute("data-url") || c.getAttribute("href") || "";
|
|
if (!/^https?:\/\//i.test(url)) url = "";
|
|
seen[id] = true;
|
|
out.push({
|
|
id: id,
|
|
url: url,
|
|
headline: c.getAttribute("data-headline") || "",
|
|
lede: c.getAttribute("data-lede") || "",
|
|
image: c.getAttribute("data-image") || "",
|
|
time: c.getAttribute("data-time") || "",
|
|
source: c.getAttribute("data-source") || "",
|
|
chTitle: c.getAttribute("data-ch-title") || "",
|
|
chEmoji: c.getAttribute("data-ch-emoji") || "",
|
|
chTheme: c.getAttribute("data-ch-theme") || "",
|
|
paywalled: c.getAttribute("data-paywalled") === "1"
|
|
});
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ---- rendering ------------------------------------------------------------
|
|
function escapeHTML(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
function safeURL(s) {
|
|
var raw = String(s == null ? "" : s).trim();
|
|
return /^https?:\/\//i.test(raw) ? raw : "";
|
|
}
|
|
// Turn stored plain text (paragraphs separated by blank lines) into escaped
|
|
// <p> blocks. Single newlines inside a paragraph become spaces.
|
|
function paragraphs(text) {
|
|
var blocks = String(text || "").split(/\n{2,}/);
|
|
var html = "";
|
|
for (var i = 0; i < blocks.length; i++) {
|
|
var t = blocks[i].replace(/\s*\n\s*/g, " ").trim();
|
|
if (t) html += "<p>" + escapeHTML(t) + "</p>";
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function eyebrow(it) {
|
|
var chip = it.chTheme
|
|
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.chTheme) + '">' +
|
|
(it.chEmoji ? '<span aria-hidden="true">' + escapeHTML(it.chEmoji) + "</span>" : "") +
|
|
escapeHTML(it.chTitle) + "</span>"
|
|
: "";
|
|
var src = it.source ? '<span class="pete-reader-source">' + escapeHTML(it.source) + "</span>" : "";
|
|
var time = it.time ? '<span class="pete-reader-time">' + escapeHTML(it.time) + "</span>" : "";
|
|
return '<div class="pete-reader-eyebrow">' + chip + src + time + "</div>";
|
|
}
|
|
|
|
function renderBody(it, bodyHTML) {
|
|
var hero = it.image
|
|
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="" loading="lazy" decoding="async">'
|
|
: "";
|
|
var href = safeURL(it.url);
|
|
var note = '<p class="pete-reader-note">' +
|
|
(it.paywalled ? "This source is paywalled — the text above may be partial. " : "") +
|
|
(href ? 'Read it at the source: <a href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
|
|
escapeHTML(it.source || "original article") + " ↗</a>." : "") +
|
|
"</p>";
|
|
articleEl.innerHTML =
|
|
eyebrow(it) +
|
|
'<h1 class="pete-reader-title">' + escapeHTML(it.headline) + "</h1>" +
|
|
hero +
|
|
'<div class="pete-reader-body">' + bodyHTML + "</div>" +
|
|
note;
|
|
}
|
|
|
|
function loadingBody(it) {
|
|
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
|
|
}
|
|
|
|
function fillContent(it, data) {
|
|
var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede);
|
|
var html = paragraphs(text);
|
|
if (!html) {
|
|
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
|
|
}
|
|
renderBody(it, html);
|
|
}
|
|
|
|
function fetchContent(it) {
|
|
if (contentCache[it.id]) { fillContent(it, contentCache[it.id]); return; }
|
|
loadingBody(it);
|
|
if (inflight) inflight.abort();
|
|
var ctrl = new AbortController();
|
|
inflight = ctrl;
|
|
var reqId = it.id;
|
|
fetch("/api/article?id=" + encodeURIComponent(it.id), { signal: ctrl.signal, credentials: "same-origin" })
|
|
.then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); })
|
|
.then(function (data) {
|
|
contentCache[reqId] = data;
|
|
if (ctrl !== inflight) return; // superseded by a newer navigation
|
|
if (items[index] && items[index].id === reqId) fillContent(it, data);
|
|
})
|
|
.catch(function (err) {
|
|
if (err.name === "AbortError") return;
|
|
if (items[index] && items[index].id === reqId) fillContent(it, null); // fall back to lede
|
|
})
|
|
.finally(function () { if (ctrl === inflight) inflight = null; });
|
|
}
|
|
|
|
// ---- related ("you might also like") --------------------------------------
|
|
function clearRelated() {
|
|
if (!relatedEl) return;
|
|
relatedEl.innerHTML = "";
|
|
relatedEl.hidden = true;
|
|
}
|
|
|
|
function renderRelated(reqId, results) {
|
|
if (!relatedEl) return;
|
|
// Ignore a response that arrived after the user moved on.
|
|
if (!items[index] || String(items[index].id) !== String(reqId)) return;
|
|
if (!results || !results.length) { clearRelated(); return; }
|
|
var html = '<h2 class="pete-reader-related-title">You might also like</h2>' +
|
|
'<div class="pete-reader-related-grid">';
|
|
for (var i = 0; i < results.length; i++) {
|
|
var it = results[i];
|
|
var href = safeURL(it.article_url);
|
|
if (!href) continue;
|
|
var chip = it.channel_theme
|
|
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.channel_theme) + '">' +
|
|
(it.channel_emoji ? '<span aria-hidden="true">' + escapeHTML(it.channel_emoji) + "</span>" : "") +
|
|
escapeHTML(it.channel_title) + "</span>"
|
|
: "";
|
|
var thumb = it.thumb_url
|
|
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="" loading="lazy" decoding="async">'
|
|
: '<div class="pete-reader-related-thumb pete-reader-related-thumb-empty"></div>';
|
|
html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
|
|
thumb +
|
|
'<div class="pete-reader-related-meta">' +
|
|
'<div class="pete-reader-related-eyebrow">' + chip +
|
|
(it.source ? '<span class="pete-reader-related-source">' + escapeHTML(it.source) + "</span>" : "") +
|
|
"</div>" +
|
|
'<div class="pete-reader-related-headline">' + escapeHTML(it.headline) + "</div>" +
|
|
"</div></a>";
|
|
}
|
|
html += "</div>";
|
|
relatedEl.innerHTML = html;
|
|
relatedEl.hidden = false;
|
|
}
|
|
|
|
function fetchRelated(it) {
|
|
if (!relatedEl) return;
|
|
clearRelated();
|
|
var reqId = it.id;
|
|
if (relatedCache[reqId]) { renderRelated(reqId, relatedCache[reqId]); return; }
|
|
fetch("/api/related?id=" + encodeURIComponent(it.id), { credentials: "same-origin" })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (data) {
|
|
var results = (data && data.results) || [];
|
|
relatedCache[reqId] = results;
|
|
renderRelated(reqId, results);
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
// ---- navigation -----------------------------------------------------------
|
|
function show(i) {
|
|
index = i;
|
|
if (index >= items.length) { renderDone(); return; }
|
|
var it = items[index];
|
|
progressEl.textContent = (index + 1) + " / " + items.length;
|
|
var href = safeURL(it.url);
|
|
if (href) { linkEl.href = href; linkEl.style.display = ""; }
|
|
else linkEl.style.display = "none";
|
|
prevBtn.disabled = index === 0;
|
|
nextBtn.disabled = false;
|
|
nextBtn.textContent = index === items.length - 1 ? "done ✓" : "→";
|
|
if (scrollEl) scrollEl.scrollTop = 0;
|
|
if (readerBookmarkBtn) {
|
|
readerBookmarkBtn.style.display = SIGNED_IN ? "" : "none";
|
|
applyReaderBookmark(isBookmarked(it.id));
|
|
}
|
|
fetchContent(it);
|
|
fetchRelated(it);
|
|
setRead(it.id, true); // presenting a story marks it read
|
|
}
|
|
|
|
function renderDone() {
|
|
clearRelated();
|
|
progressEl.textContent = items.length + " / " + items.length;
|
|
linkEl.style.display = "none";
|
|
prevBtn.disabled = items.length === 0;
|
|
nextBtn.disabled = true;
|
|
nextBtn.textContent = "→";
|
|
if (scrollEl) scrollEl.scrollTop = 0;
|
|
articleEl.innerHTML =
|
|
'<div class="pete-reader-empty">' +
|
|
'<span class="pete-reader-empty-emoji" aria-hidden="true">🎉</span>' +
|
|
'<h1 class="pete-reader-title" style="margin:0">You\'re all caught up</h1>' +
|
|
'<p style="opacity:.7;max-width:28rem">That\'s every story on this page. ' +
|
|
"Head back to browse more, or press ← to revisit the last one.</p>" +
|
|
"</div>";
|
|
}
|
|
|
|
function next() { if (index < items.length) show(index + 1); }
|
|
function prev() { if (index > 0) show(index - 1); }
|
|
|
|
function firstUnread() {
|
|
for (var i = 0; i < items.length; i++) if (!isRead(items[i].id)) return i;
|
|
return 0;
|
|
}
|
|
|
|
// ---- open / close ---------------------------------------------------------
|
|
function openReader() {
|
|
items = collect();
|
|
if (items.length === 0) return;
|
|
open = true;
|
|
overlay.classList.remove("hidden");
|
|
document.body.classList.add("overflow-hidden");
|
|
show(firstUnread());
|
|
}
|
|
function closeReader() {
|
|
open = false;
|
|
if (inflight) { inflight.abort(); inflight = null; }
|
|
clearRelated();
|
|
overlay.classList.add("hidden");
|
|
document.body.classList.remove("overflow-hidden");
|
|
}
|
|
|
|
// ---- wiring ---------------------------------------------------------------
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
// Dim stories already read on this device.
|
|
document.querySelectorAll("[data-story-card]").forEach(function (c) {
|
|
var id = c.getAttribute("data-id");
|
|
if (id && isRead(id)) c.setAttribute("data-read", "1");
|
|
});
|
|
|
|
document.querySelectorAll("[data-reader-open]").forEach(function (b) {
|
|
b.addEventListener("click", function (e) { e.preventDefault(); openReader(); });
|
|
});
|
|
if (prevBtn) prevBtn.addEventListener("click", prev);
|
|
if (nextBtn) nextBtn.addEventListener("click", next);
|
|
if (closeBtn) closeBtn.addEventListener("click", closeReader);
|
|
if (backdrop) backdrop.addEventListener("click", closeReader);
|
|
if (readerBookmarkBtn) readerBookmarkBtn.addEventListener("click", function () {
|
|
var it = items[index];
|
|
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
|
});
|
|
|
|
initUserState();
|
|
});
|
|
|
|
// Bookmark buttons live inside the card's <a>; intercept so a tap toggles the
|
|
// bookmark instead of following the link. Delegated so it also covers cards
|
|
// that are added or removed after load.
|
|
document.addEventListener("click", function (e) {
|
|
var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
|
|
if (!btn) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var id = btn.getAttribute("data-story-id");
|
|
if (id) setBookmark(id, !isBookmarked(id));
|
|
});
|
|
document.addEventListener("keydown", function (e) {
|
|
if (e.key !== "Enter" && e.key !== " ") return;
|
|
var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
|
|
if (!btn) return;
|
|
e.preventDefault();
|
|
var id = btn.getAttribute("data-story-id");
|
|
if (id) setBookmark(id, !isBookmarked(id));
|
|
});
|
|
|
|
document.addEventListener("keydown", function (e) {
|
|
// Enter feed mode with `f` when nothing is focused and no other overlay is up.
|
|
if (!open && (e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
var tag = (document.activeElement && document.activeElement.tagName) || "";
|
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
var searchOpen = document.getElementById("pete-search");
|
|
if (searchOpen && !searchOpen.classList.contains("hidden")) return;
|
|
e.preventDefault();
|
|
openReader();
|
|
return;
|
|
}
|
|
if (!open) return;
|
|
switch (e.key) {
|
|
case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
|
|
case "ArrowLeft": case "h": e.preventDefault(); prev(); break;
|
|
case "Escape": e.preventDefault(); closeReader(); break;
|
|
case "o": case "Enter": {
|
|
var it = items[index];
|
|
var href = it && safeURL(it.url);
|
|
if (href) { e.preventDefault(); window.open(href, "_blank", "noopener"); }
|
|
break;
|
|
}
|
|
case "u": {
|
|
var cur = items[index];
|
|
if (cur) setRead(cur.id, false); // let the user undo an accidental read
|
|
break;
|
|
}
|
|
case "b": case "B": {
|
|
if (!SIGNED_IN) break;
|
|
var it = items[index];
|
|
if (it) { e.preventDefault(); setBookmark(it.id, !isBookmarked(it.id)); }
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
})();
|