diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 4c89ac1..34aec4d 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -294,6 +294,62 @@ const server = http.createServer(async (req, res) => { return; } + // --- unified search: label + content --- + // GET /api/search/ -> { query, count, results: [{ name, tld, matchType, score, snippet, records }] } + // Scores: label-exact (100) > label-substring (70) > h-record content (50). + // Content search covers on-chain inline HTML only; Sia/ip sites await Helios v1. + // Same cached index as /api/labels/ and /api/name/ — no extra fetch cost on the hot path. + if (req.url.startsWith("/api/search/")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("content-type", "application/json"); + const q = decodeURIComponent(req.url.slice("/api/search/".length).split("?")[0]).toLowerCase().trim(); + if (!q || q.length < 2 || q.length > 64) { + res.writeHead(400); + res.end(JSON.stringify({ error: "query must be 2-64 chars" })); + return; + } + try { + const idx = await getIndex(); + const results = []; + for (const [key, entry] of idx) { + const dot = key.indexOf("."); + const label = dot < 0 ? key : key.slice(0, dot); + const tld = dot < 0 ? "bch" : key.slice(dot + 1); + const displayName = dot < 0 ? key + ".bch" : key; + const records = entry?.records ?? {}; + + let matchType = null, score = 0, snippet = null; + if (label === q) { matchType = "label-exact"; score = 100; } + else if (label.includes(q)) { matchType = "label-substring"; score = 70; } + + // Content search over inline HTML (h record). Strip tags first so a + // query like "poutakidis" doesn't accidentally match "class=poutakidis". + if (typeof records.h === "string" && records.h) { + const text = records.h.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + const i = text.toLowerCase().indexOf(q); + if (i >= 0) { + if (!matchType) { matchType = "content"; score = 50; } + const start = Math.max(0, i - 40); + const end = Math.min(text.length, i + q.length + 60); + snippet = (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : ""); + } + } + + if (matchType) { + results.push({ name: displayName, tld, matchType, score, snippet, + records: entry?.records ?? null, category: entry?.category ?? null }); + } + } + results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + const capped = results.slice(0, 50); + res.writeHead(200); + res.end(JSON.stringify({ query: q, count: capped.length, truncated: results.length > 50, results: capped })); + } catch (e) { + res.writeHead(500); res.end(JSON.stringify({ error: e.message })); + } + return; + } + // --- subdomain form: .bch. --- if (hostHeader.endsWith("." + SUB_BASE)) { const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // ".bch" or ""