Changelog 151a6b5
survey scanner
5 files of code, design or docs +144 −13 lines
Code, design and documents 5 files
source-archive.json +11 −11
Not shown: a picture, a generated file, or past the length shown per change.
src/layouts/Base.astro +3 −2
@@ -5,5 +5,6 @@ import Leaf from "../components/Leaf.astro"; // `seoTitle` replaces the whole <title> (search-result wording); `jsonld` is one schema.org // object or a list of them; `type` is the Open Graph type of the page. -const { title, seoTitle, description, jsonld, type = "website" } = Astro.props; +// `noindex` keeps a page out of search engines (hidden utility pages) +const { title, seoTitle, description, jsonld, type = "website", noindex = false } = Astro.props; const base = import.meta.env.BASE_URL; const repo = "https://github.com/burningtree/cannabis-freedom-index"; @@ -22,5 +23,5 @@ const schemas = jsonld ? [jsonld].flat() : []; <meta name="description" content={description}> <link rel="canonical" href={canonical}> - <meta name="robots" content="index, follow, max-snippet:-1"> + <meta name="robots" content={noindex ? "noindex, nofollow" : "index, follow, max-snippet:-1"}> <meta property="og:site_name" content={siteName}> <meta property="og:type" content={type}>
src/lib/survey-client.js +31 −0
@@ -135,2 +135,33 @@ export async function fetchMine(cfg) { return out; } + +// ── The public view ── +// Everything a stranger can learn from the relays without any key: which messages exist, under +// which pseudonymous keys, of which kind, how large and when. Used by /survey/scan/. Each relay +// is asked by itself and timed; the contents stay encrypted and are not touched. +export async function scan(cfg) { + const filter = { kinds: [cfg.kind], "#t": [cfg.topic] }; + const relays = await Promise.all(cfg.relays.map(async (url) => { + const pool = new SimplePool(), started = Date.now(); + try { const events = await within(pool.querySync([url], filter), 10000); return { url, ok: true, ms: Date.now() - started, events }; } + catch (e) { return { url, ok: false, ms: Date.now() - started, error: String(e.message ?? e), events: [] }; } + finally { pool.close([url]); } + })); + const byId = new Map(); + for (const r of relays) for (const e of r.events) { + const d = e.tags.find((t) => t[0] === "d")?.[1] ?? ""; + const part = d.slice(cfg.topic.length + 1).split(":")[0]; // response | place | price + const row = byId.get(e.id) ?? { + id: e.id, pubkey: e.pubkey, npub: npubEncode(e.pubkey), created_at: e.created_at, part, d, + bytes: e.content.length, encrypted: !e.content.startsWith("{"), relays: [], + url: `https://njump.me/${naddrEncode({ kind: e.kind, pubkey: e.pubkey, identifier: d, relays: [r.url] })}`, + }; + row.relays.push(r.url); + byId.set(e.id, row); + } + // a replaced version may linger on one relay: keep only the newest message per key and part + const newest = new Map(); + for (const row of byId.values()) { const slot = `${row.pubkey}:${row.d}`; if (!newest.has(slot) || newest.get(slot).created_at < row.created_at) newest.set(slot, row); } + const events = [...newest.values()].sort((a, b) => b.created_at - a.created_at); + return { relays: relays.map(({ events: found, ...r }) => ({ ...r, count: found.length })), events, stale: byId.size - events.length }; +}
src/pages/survey/scan.astro +91 −0
@@ -0,0 +1,91 @@ +--- +// State of Cannabis: the public scanner. It asks the survey's relays for every submission and +// shows exactly what anyone on the internet can see of them without a key: how many there are, +// under which pseudonymous keys, of which kind, how large and when. The answers themselves are +// encrypted and stay unreadable here. The page is not linked from the menu and not indexed. +import Base from "../../layouts/Base.astro"; +import { survey, submitConfig } from "../../lib/survey.js"; + +const base = import.meta.env.BASE_URL; +--- +<Base title="Submission scanner" description="What the public Nostr relays hold of the State of Cannabis survey, as anyone can see it." noindex> + <main class="changes-page survey sv-scan" data-config={JSON.stringify(submitConfig)}> + <section class="hero hero-sm"> + <p class="eyebrow"><a href={`${base}survey/`}>State of Cannabis</a> <span>{survey.edition} edition</span></p> + <h1>Submission scanner</h1> + <p class="lede">A live look at the survey's public relays: every submission they hold, as any stranger can see it. The answers are encrypted, so what shows is only that a message exists, which random key signed it, what kind of part it is, how big it is and when it was last changed.</p> + <p class="front-go"><button type="button" class="go solid" id="scanRun">Scan the relays</button></p> + </section> + + <p class="sv-mock" role="note" id="scanNote" hidden></p> + + <dl class="hist-stats" id="scanStats" hidden></dl> + + <section class="section" id="scanRelaysBox" hidden> + <div class="section-head"><div><h2>Relays</h2><p class="sub">Each relay is asked by itself. A message should be on all of them.</p></div></div> + <table class="src-table"><thead><tr><th>Relay</th><th>Answer</th><th class="num">Messages</th><th class="num">Time</th></tr></thead><tbody id="scanRelays"></tbody></table> + </section> + + <section class="section" id="scanEventsBox" hidden> + <div class="section-head"><div><h2>Submissions</h2><p class="sub" id="scanSub"></p></div></div> + <div class="sv-table-wrap"> + <table class="src-table sv-scan-table"><thead><tr><th>Changed</th><th>Key</th><th>Part</th><th class="num">Size</th><th class="num">Relays</th><th>Event</th></tr></thead><tbody id="scanEvents"></tbody></table> + </div> + </section> + <noscript><p>This page needs JavaScript: it asks the relays from your browser.</p></noscript> + </main> + + <script> + const root = document.querySelector(".sv-scan"); + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s).replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]); + const when = (t) => new Date(t * 1000).toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }); + const PART = { response: "Survey", place: "Place report", price: "Price report" }; + const button = $("scanRun"), note = $("scanNote"); + + async function run() { + button.disabled = true; + button.textContent = "Scanning…"; + note.hidden = true; + try { + const client = await import("../../lib/survey-client.js"); + const cfg = client.effective(JSON.parse(root.dataset.config)); + if (!cfg.open) { note.hidden = false; note.innerHTML = "<strong>The survey is not open.</strong> No relays are set, so there is nothing to scan."; return; } + const { relays, events, stale } = await client.scan(cfg); + const keys = new Set(events.map((e) => e.pubkey)); + const count = (part) => events.filter((e) => e.part === part).length; + const mine = client.identity()?.pubkey; + + $("scanStats").hidden = false; + $("scanStats").innerHTML = [[events.length, "Messages"], [keys.size, "Keys"], [count("response"), "Surveys"], [count("place"), "Place reports"], [count("price"), "Price reports"], [`${relays.filter((r) => r.ok).length}/${relays.length}`, "Relays answering"]] + .map(([n, label]) => `<div><dt>${label}</dt><dd>${n}</dd></div>`).join(""); + + $("scanRelaysBox").hidden = false; + $("scanRelays").innerHTML = relays.map((r) => `<tr><td class="mono">${esc(r.url)}</td><td>${r.ok ? "OK" : `<span class="sv-bad">${esc(r.error)}</span>`}</td><td class="num">${r.ok ? r.count : "–"}</td><td class="num">${r.ms} ms</td></tr>`).join(""); + + $("scanEventsBox").hidden = false; + $("scanSub").textContent = events.length + ? `The newest version of each part, newest first.${stale ? ` ${stale} older version${stale === 1 ? " is" : "s are"} still held by a relay and not shown.` : ""} A key is a random pseudonym; messages with the same key come from one browser.${mine ? " Yours are marked." : ""}` + : "The relays hold no submissions yet."; + // the same short colour for the same key, so one browser's parts can be told at a glance + const tint = (pk) => `hsl(${parseInt(pk.slice(0, 6), 16) % 360} 55% 55%)`; + $("scanEvents").innerHTML = events.map((e) => `<tr${e.pubkey === mine ? ' class="sv-own"' : ""}> + <td class="mono">${when(e.created_at)}</td> + <td class="mono"><span class="sv-dot" style="background:${tint(e.pubkey)}"></span><span title="${e.npub}">${e.npub.slice(0, 12)}…${e.npub.slice(-4)}</span>${e.pubkey === mine ? ' <small>you</small>' : ""}</td> + <td>${esc(PART[e.part] ?? e.part)}</td> + <td class="num">${e.encrypted ? `${e.bytes.toLocaleString("en-GB")} B` : '<span class="sv-bad">not encrypted</span>'}</td> + <td class="num">${e.relays.length}/${relays.length}</td> + <td class="mono"><a href="${e.url}" target="_blank" rel="noopener noreferrer" title="${e.id}">${e.id.slice(0, 10)}… ↗</a></td> + </tr>`).join(""); + } catch (err) { + note.hidden = false; + note.textContent = `The scan failed: ${err.message}`; + } finally { + button.disabled = false; + button.textContent = "Scan again"; + } + } + button.addEventListener("click", run); + run(); + </script> +</Base>
src/styles/global.css +8 −0
@@ -1573,2 +1573,10 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase .sv-about p { margin: 0; } .sv-about a { text-decoration: underline; text-underline-offset: 3px; } +/* /survey/scan/: what the relays show to anyone */ +.sv-scan .hist-stats { grid-template-columns: repeat(6, minmax(0, 1fr)); } +.sv-scan-table { min-width: 760px; } +.sv-scan-table td small { font: 400 10.5px var(--mono); color: var(--t5); text-transform: uppercase; letter-spacing: .05em; margin-left: 6px; } +.sv-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; vertical-align: 1px; } +.sv-own td { background: color-mix(in srgb, var(--t4) 8%, transparent); } +.sv-bad { color: var(--t1); } +@media (max-width: 900px) { .sv-scan .hist-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }