- 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
190 lines
6.7 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
// 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";
|
|
})();
|