Files
Pete/internal/web/static/js/search.js
prosolis 9e5ba5aafc Fix SSRF, XSS, dedup, force-post, and DB hot-path issues
- Add internal/safehttp: hardened HTTP client (DNS-resolved dial guard
  blocking loopback/RFC1918/CGNAT/link-local, redirect re-validation,
  body-size cap) and rewire article/feed/thumb clients through it
- Cap goquery body at 5 MiB so a hostile origin can't OOM the process
- search.js: reject non-http(s) hrefs to block stored XSS via javascript:
- dedup: tracking-param key "CMP" was unreachable (lookup lowercases);
  fixed to "cmp" so CMP= is actually stripped from canonical URLs
- ForcePost: postItem now returns bool; on dedup-skip ForcePost returns
  false so !post falls back to DB lookup instead of silently consuming
- Bound reaction callbacks behind an 8-slot semaphore; drop overflow
- Add stories indexes on (channel, classified, seen_at DESC),
  (classified, seen_at DESC), and partial image_url to kill full scans
  in IsKnownImageURL and ORDER BY seen_at hot paths
- Surface FTS5 probe Scan error instead of swallowing it
2026-05-25 12:23:37 -07:00

190 lines
6.7 KiB
JavaScript

// Cmd+K / Ctrl+K command palette. Hits /api/search and renders editorial-card
// results. Vanilla JS, no deps; styles come from output.css (Tailwind).
(function () {
const overlay = document.getElementById("pete-search");
if (!overlay) return;
const input = overlay.querySelector("[data-search-input]");
const list = overlay.querySelector("[data-search-list]");
const meta = overlay.querySelector("[data-search-meta]");
let activeIndex = -1;
let items = [];
let inflight = null;
let debounceTimer = 0;
let lastQuery = "";
function open() {
if (!overlay.classList.contains("hidden")) return;
overlay.classList.remove("hidden");
document.body.classList.add("overflow-hidden");
requestAnimationFrame(() => input.focus());
}
function close() {
overlay.classList.add("hidden");
document.body.classList.remove("overflow-hidden");
input.value = "";
list.innerHTML = "";
meta.textContent = "";
items = [];
activeIndex = -1;
lastQuery = "";
}
function escapeHTML(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// safeURL rejects any href whose scheme isn't http(s). Stored article_url
// comes from feeds we don't control; a hostile feed publishing
// `javascript:fetch(...)` would otherwise execute on click.
function safeURL(s) {
const raw = String(s == null ? "" : s).trim();
if (/^https?:\/\//i.test(raw)) return raw;
return "";
}
function render(results) {
items = results || [];
activeIndex = items.length > 0 ? 0 : -1;
if (items.length === 0) {
list.innerHTML = "";
return;
}
const html = items.map((r, i) => {
const thumb = r.thumb_url
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-[color:var(--ink)]/5">
<img src="${escapeHTML(r.thumb_url)}" alt="" loading="lazy" decoding="async"
class="h-full w-full object-cover">
</div>`
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;
const postedRing = r.posted ? ` border-theme-${escapeHTML(r.channel_theme)}` : "";
const href = safeURL(r.article_url);
if (!href) return "";
return `
<a href="${escapeHTML(href)}" target="_blank" rel="noopener noreferrer"
data-idx="${i}"
class="search-result group flex items-start gap-4 rounded-2xl p-3 transition border-2 border-transparent hover:bg-[color:var(--ink)]/5${postedRing}">
${thumb}
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center gap-2 text-xs flex-wrap">
<span class="inline-flex items-center rounded-full bg-theme-${escapeHTML(r.channel_theme)} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
<span aria-hidden="true" class="mr-1">${escapeHTML(r.channel_emoji)}</span>${escapeHTML(r.channel_title)}
</span>
<span class="text-[color:var(--ink)]/60 font-semibold">${escapeHTML(r.source)}</span>
<span class="text-[color:var(--ink)]/50">${escapeHTML(r.time_ago)}</span>
</div>
<h3 class="font-display text-base sm:text-lg font-semibold leading-snug">${escapeHTML(r.headline)}</h3>
${r.lede ? `<p class="text-sm text-[color:var(--ink)]/70 line-clamp-2">${escapeHTML(r.lede)}</p>` : ""}
</div>
</a>`;
}).join("");
list.innerHTML = html;
paintActive();
}
function paintActive() {
const nodes = list.querySelectorAll(".search-result");
nodes.forEach((n, i) => {
if (i === activeIndex) {
n.classList.add("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15");
n.scrollIntoView({ block: "nearest" });
} else {
n.classList.remove("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15");
}
});
}
async function runQuery(q) {
if (q === lastQuery) return;
lastQuery = q;
if (!q) {
render([]);
meta.textContent = "type to search · esc to close";
return;
}
if (inflight) inflight.abort();
const ctrl = new AbortController();
inflight = ctrl;
meta.textContent = "searching…";
try {
const res = await fetch("/api/search?q=" + encodeURIComponent(q), { signal: ctrl.signal });
if (!res.ok) throw new Error("search failed: " + res.status);
const data = await res.json();
if (ctrl !== inflight) return;
render(data.results || []);
const n = (data.results || []).length;
meta.textContent = n === 0 ? "no matches" : `${n} result${n === 1 ? "" : "s"}`;
} catch (err) {
if (err.name === "AbortError") return;
console.error(err);
meta.textContent = "search error";
} finally {
if (ctrl === inflight) inflight = null;
}
}
input.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = input.value.trim();
debounceTimer = window.setTimeout(() => runQuery(q), 120);
});
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (items.length === 0) return;
activeIndex = (activeIndex + 1) % items.length;
paintActive();
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (items.length === 0) return;
activeIndex = (activeIndex - 1 + items.length) % items.length;
paintActive();
} else if (e.key === "Enter") {
if (activeIndex >= 0 && items[activeIndex]) {
e.preventDefault();
const href = safeURL(items[activeIndex].article_url);
if (href) window.open(href, "_blank", "noopener");
}
}
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
document.addEventListener("keydown", (e) => {
const isK = e.key === "k" || e.key === "K";
if ((e.metaKey || e.ctrlKey) && isK) {
e.preventDefault();
if (overlay.classList.contains("hidden")) open();
else close();
return;
}
if (e.key === "Escape" && !overlay.classList.contains("hidden")) {
close();
}
if (e.key === "/" && overlay.classList.contains("hidden")) {
const tag = (document.activeElement && document.activeElement.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA") return;
e.preventDefault();
open();
}
});
document.querySelectorAll("[data-search-trigger]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
open();
});
});
meta.textContent = "type to search · esc to close";
})();