1// Views: folds over what the relay holds, published as records the relay2// signs (kind 30078, d = bind.ws/view/<name>) so a client reads one event3// instead of recomputing from hundreds. Each view names its trigger, which4// is its cost in rows written:5//6// daily once a day from the alarm7// hourly once an hour from the alarm, only when a fingerprint changed8// write when an accepted event touches it, coalesced, plus daily9// live never stored: presence, an ephemeral event from memory10//11// A public view is stored and broadcast like the roster. A members-only12// view is never stored, since a stored event is readable by anyone the read13// rule lets in; it is folded and signed on request at GET /view/<name>.14import { bolt11Msats } from "./fuel.ts";15import { now, tag, tagValues, type Event } from "./event.ts";16import type { Relay } from "./relay.ts";17import { KIND_VIEW, KIND_PRESENCE } from "./kinds.ts";18import type { ViewSetting } from "./settings.ts";1920export const PRESENCE_ACTIVE_S = 15 * 60;21export const PRESENCE_THROTTLE_S = 30;22export const viewD = (name: string) => "bind.ws/view/" + name;2324export type Trigger = "daily" | "hourly" | "write" | "live";25export type Audience = "public" | "members";26export interface Fold {27 tags: string[][];28 content: string;29}30export interface View {31 name: string;32 trigger: Trigger;33 about: string;34 audience: (relay: Relay) => Audience;35 // fingerprint, for hourly views: republish only when it moved.36 fingerprint?: (relay: Relay) => string;37 fold: (relay: Relay) => Fold | null; // null takes the record down38}39export interface ViewRun {40 at: number;41 rows: number;42}4344const addr = (kind: number, pubkey: string, d: string) => `${kind}:${pubkey}:${d}`;45const parse = (raw: string) => JSON.parse(raw) as Event;46const byReads = (relay: Relay): Audience => (relay.settings.policy.reads === "members" ? "members" : "public");47const byDirectory = (relay: Relay): Audience => (relay.settings.policy.directoryPublic ? "public" : "members");4849// The pubkeys a view speaks for: the owner first, then members.50function people(relay: Relay): string[] {51 const m = relay.settings.members();52 const owner = relay.settings.policy.owner;53 return [owner, ...m.map((x) => x.pubkey).filter((p) => p !== owner)].filter(Boolean);54}5556// newest returns the stored replaceable event of `kind` for each pubkey.57function newest(relay: Relay, kind: number, pubkeys: string[]): Map<string, Event> {58 const out = new Map<string, Event>();59 if (pubkeys.length === 0) return out;60 for (const r of relay.sql.exec<{ pubkey: string; raw: string }>(`SELECT pubkey, raw FROM events WHERE kind=? AND pubkey IN (SELECT value FROM json_each(?)) ORDER BY created_at DESC`, kind, JSON.stringify(pubkeys))) {61 if (!out.has(r.pubkey)) out.set(r.pubkey, parse(r.raw));62 }63 return out;64}6566const profiles: View = {67 name: "profiles",68 trigger: "daily",69 about: "every member's newest profile in one record",70 audience: byDirectory,71 fold(relay) {72 const who = people(relay);73 const got = newest(relay, 0, who);74 const host = relay.slug + "." + relay.domain;75 const tags = who.map((pk) => {76 const e = got.get(pk);77 let meta: { name?: string; picture?: string; nip05?: string } = {};78 try {79 meta = e ? JSON.parse(e.content) : {};80 } catch {81 /* a profile that is not JSON */82 }83 const member = relay.settings.member(pk);84 const nip05 = member?.name ? `${member.name}@${host}` : typeof meta.nip05 === "string" ? meta.nip05 : "";85 return ["p", pk, String(meta.name ?? "").slice(0, 200), String(meta.picture ?? "").slice(0, 2000), nip05.slice(0, 200)];86 });87 return { tags, content: "" };88 },89};9091const relays: View = {92 name: "relays",93 trigger: "daily",94 about: "where the members are: the union of their relay lists",95 audience: byDirectory,96 fold(relay) {97 const counts = new Map<string, number>();98 for (const e of newest(relay, 10002, people(relay)).values()) {99 const seen = new Set<string>();100 for (const t of e.tags) {101 if (t[0] !== "r" || typeof t[1] !== "string") continue;102 let u = "";103 try {104 const x = new URL(t[1].trim());105 if (x.protocol !== "wss:" && x.protocol !== "ws:") continue;106 u = (x.host + x.pathname).replace(/\/+$/, "").toLowerCase();107 u = x.protocol + "//" + u;108 } catch {109 continue;110 }111 if (seen.has(u)) continue;112 seen.add(u);113 counts.set(u, (counts.get(u) ?? 0) + 1);114 }115 }116 const tags = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 100).map(([u, n]) => ["r", u, String(n)]);117 return { tags, content: "" };118 },119};120121// A NIP-52 start: a date for kind 31922, a unix time for 31923.122function startOf(e: Event): number {123 const s = tag(e, "start");124 if (e.kind === 31922) {125 const t = Date.parse(s + "T00:00:00Z");126 return Number.isFinite(t) ? Math.floor(t / 1000) : 0;127 }128 const n = Number(s);129 return Number.isInteger(n) && n > 0 ? n : 0;130}131132const calendar: View = {133 name: "calendar",134 trigger: "hourly",135 about: "what is on: calendar events starting in the next 30 days, with RSVPs counted",136 audience: byReads,137 fingerprint(relay) {138 const r = relay.sql.exec<{ n: number; m: number | null }>(`SELECT count(*) AS n, max(created_at) AS m FROM events WHERE kind IN (31922,31923,31925)`).one();139 return `${r.n}:${r.m ?? 0}:${Math.floor(now() / 86400)}`;140 },141 fold(relay) {142 const t = now();143 const upcoming: { a: string; start: number; title: string }[] = [];144 for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind IN (31922,31923) ORDER BY created_at DESC LIMIT 1000`)) {145 const e = parse(r.raw);146 const start = startOf(e);147 if (start < t - 86400 || start > t + 30 * 86400) continue;148 upcoming.push({ a: addr(e.kind, e.pubkey, tag(e, "d")), start, title: tag(e, "title").slice(0, 200) });149 }150 upcoming.sort((a, b) => a.start - b.start);151 const top = upcoming.slice(0, 50);152 const rsvps = new Map<string, Set<string>>();153 if (top.length) {154 for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=31925 ORDER BY created_at DESC LIMIT 5000`)) {155 const e = parse(r.raw);156 if (tag(e, "status") !== "accepted") continue;157 for (const a of tagValues(e, "a")) {158 if (!rsvps.has(a)) rsvps.set(a, new Set());159 rsvps.get(a)!.add(e.pubkey);160 }161 }162 }163 const tags = top.map((u) => {164 const n = rsvps.get(u.a)?.size ?? 0;165 return n ? ["a", u.a, String(u.start), u.title, String(n)] : ["a", u.a, String(u.start), u.title];166 });167 return { tags, content: "" };168 },169};170171const moderation: View = {172 name: "moderation",173 trigger: "daily",174 about: "this month's moderation counts, no ids",175 audience: byReads,176 fold(relay) {177 const d = new Date(now() * 1000);178 const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;179 const one = (q: string, ...args: unknown[]) => relay.sql.exec<{ n: number | null }>(q, ...args).one().n ?? 0;180 const counts = {181 month: d.toISOString().slice(0, 7),182 bans: one(`SELECT count(*) AS n FROM pubkey_rules WHERE rule='ban' AND at>=?`, monthStart),183 reports: one(`SELECT count(*) AS n FROM reports WHERE at>=?`, monthStart),184 resolved: one(`SELECT count(*) AS n FROM reports WHERE resolved_at>=?`, monthStart),185 hidden: one(`SELECT count(*) AS n FROM event_rules WHERE rule='hide' AND at>=?`, monthStart),186 deleted: one(`SELECT count(*) AS n FROM event_rules WHERE rule='ban' AND at>=?`, monthStart),187 blocked_addresses: one(`SELECT count(*) AS n FROM ip_rules WHERE at>=?`, monthStart),188 };189 return { tags: [["month", counts.month]], content: JSON.stringify(counts) };190 },191};192193const articles: View = {194 name: "articles",195 trigger: "write",196 about: "the newest hundred articles, by address",197 audience: byReads,198 fold(relay) {199 const tags: string[][] = [];200 for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=30023 ORDER BY created_at DESC LIMIT 100`)) {201 const e = parse(r.raw);202 const published = tag(e, "published_at");203 tags.push(["a", addr(e.kind, e.pubkey, tag(e, "d")), tag(e, "title").slice(0, 200), /^\d+$/.test(published) ? published : String(e.created_at)]);204 }205 return { tags, content: "" };206 },207};208209const zaps: View = {210 name: "zaps",211 trigger: "hourly",212 about: "zap totals for the top notes and authors here",213 audience: byReads,214 fingerprint(relay) {215 const r = relay.sql.exec<{ n: number; m: number | null }>(`SELECT count(*) AS n, max(created_at) AS m FROM events WHERE kind=9735`).one();216 return `${r.n}:${r.m ?? 0}`;217 },218 fold(relay) {219 const byEvent = new Map<string, number>();220 const byAuthor = new Map<string, number>();221 const here = new Set(people(relay));222 const self = relay.identity.pubkey;223 const service = relay.fuel.cfg.servicePubkey;224 for (const r of relay.sql.exec<{ raw: string }>(`SELECT raw FROM events WHERE kind=9735 ORDER BY created_at DESC LIMIT 5000`)) {225 const receipt = parse(r.raw);226 let req: Event;227 try {228 req = JSON.parse(tag(receipt, "description"));229 } catch {230 continue;231 }232 if (!req || req.kind !== 9734 || !Array.isArray(req.tags)) continue;233 const msats = bolt11Msats(tag(receipt, "bolt11"));234 if (msats <= 0) continue;235 let to = tag(req, "p");236 if (service && to === service && self) to = self;237 if (to && (here.has(to) || to === self)) byAuthor.set(to, (byAuthor.get(to) ?? 0) + msats);238 const eid = tag(req, "e");239 if (/^[0-9a-f]{64}$/.test(eid) && relay.sql.exec(`SELECT 1 FROM events WHERE id=?`, eid).toArray().length) byEvent.set(eid, (byEvent.get(eid) ?? 0) + msats);240 }241 const top = (m: Map<string, number>, key: string) => [...m].sort((a, b) => b[1] - a[1]).slice(0, 50).map(([k, v]) => [key, k, String(v)]);242 return { tags: [...top(byEvent, "e"), ...top(byAuthor, "p")], content: "" };243 },244};245246const presence: View = {247 name: "presence",248 trigger: "live",249 about: "who is connected now and who wrote in the last 15 minutes",250 audience: byReads,251 fold(relay) {252 return { tags: presenceTags(relay), content: "" };253 },254};255256export const VIEWS: View[] = [profiles, relays, calendar, moderation, articles, zaps, presence];257export const viewByName = (name: string): View | undefined => VIEWS.find((v) => v.name === name);258// viewChoices is what the owner may set a view to: a stored view runs on a259// write, hourly or daily; the live presence view is on or off.260export const viewChoices = (v: View): ViewSetting[] => (v.trigger === "live" ? ["off"] : ["off", "write", "hourly", "daily"]);261// viewTrigger is how a view runs as set: its own trigger unless the owner262// chose another it can take, or "off".263export function viewTrigger(relay: Relay, name: string): Trigger | "off" {264 const v = viewByName(name);265 if (!v) return "off";266 const set = relay.settings.policy.views[name];267 if (set === "off") return "off";268 if (set && v.trigger !== "live" && viewChoices(v).includes(set)) return set;269 return v.trigger;270}271export const viewOn = (relay: Relay, name: string): boolean => viewTrigger(relay, name) !== "off";272export const viewStored = (relay: Relay, v: View): boolean => v.trigger !== "live" && v.audience(relay) === "public";273274// presenceTags folds the socket list and the recent writers: online beats active.275export function presenceTags(relay: Relay): string[][] {276 const t = now();277 const online = new Set(relay.authedNow());278 const tags: string[][] = [];279 for (const pk of online) tags.push(["p", pk, "online"]);280 for (const [pk, at] of relay.presenceActive) {281 if (t - at > PRESENCE_ACTIVE_S) relay.presenceActive.delete(pk);282 else if (!online.has(pk)) tags.push(["p", pk, "active"]);283 }284 return tags;285}286287// viewEvent signs a fold as the view's record. Stored views use the group288// clock so a newer record always replaces the older one.289export function viewEvent(relay: Relay, v: View, fold: Fold, stored: boolean): Event {290 const tags = [["d", viewD(v.name)], ["trigger", v.trigger], ...fold.tags];291 if (stored) return relay.identity.view(tags, fold.content);292 return relay.identity.sign(KIND_VIEW, tags, fold.content);293}294295// latestStored reads the record the relay last published for a view.296export function latestStored(relay: Relay, name: string): string | null {297 const self = relay.identity.pubkey;298 if (!self) return null;299 const row = relay.sql.exec<{ raw: string }>(`SELECT e.raw FROM events e JOIN tags t ON t.event_id=e.id WHERE e.kind=? AND e.pubkey=? AND t.name='d' AND t.value=? ORDER BY e.created_at DESC LIMIT 1`, KIND_VIEW, self, viewD(name)).toArray()[0];300 return row ? row.raw : null;301}302303// nip11Views describes the views a relay keeps, for the information document.304export function nip11Views(relay: Relay) {305 return VIEWS.filter((v) => viewOn(relay, v.name)).map((v) => ({ name: v.name, kind: v.trigger === "live" ? KIND_PRESENCE : KIND_VIEW, d: viewD(v.name), trigger: viewTrigger(relay, v.name), audience: v.audience(relay) }));306}307308// GET /view/<name>: the stored record, or a members-only fold signed on the309// spot for a member who proves it with NIP-98, or presence from memory.310// verify is the relay's NIP-98 check, passed in so this module does not import the management door.311export async function serveView(relay: Relay, req: Request, verify: (header: string, url: string, method: string, body: string) => Event | string): Promise<Response> {312 const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" } });313 const name = new URL(req.url).pathname.slice("/view/".length);314 const v = viewByName(name);315 if (!v || !viewOn(relay, name)) return json({ error: "not found" }, 404);316 if (relay.settings.policy.owner === "" || !relay.identity.pubkey) return json({ error: "not found" }, 404);317 if (v.audience(relay) === "members") {318 const header = req.headers.get("authorization");319 const auth = header ? verify(header, req.url, req.method, "") : "auth-required: this view is for members; sign the request";320 if (typeof auth === "string") return json({ error: auth }, 401);321 if (!relay.settings.isAllowed(auth.pubkey)) return json({ error: "restricted: this view is for members" }, 403);322 }323 if (v.trigger === "live") return json(relay.identity.sign(KIND_PRESENCE, [["d", viewD(v.name)], ...presenceTags(relay)], ""));324 if (viewStored(relay, v)) {325 const raw = latestStored(relay, name);326 return raw ? new Response(raw, { headers: { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "public, max-age=60" } }) : json({ error: "not yet" }, 404);327 }328 const fold = v.fold(relay);329 return fold ? json(viewEvent(relay, v, fold, false)) : json({ error: "not yet" }, 404);330}331332// viewsSummary lists every view with its state, for the console and NIP-86.333export async function viewsSummary(relay: Relay) {334 const out = [];335 for (const v of VIEWS) {336 const runs = await viewRuns(relay, v.name);337 const last = runs[runs.length - 1] ?? null;338 out.push({ name: v.name, about: v.about, trigger: viewTrigger(relay, v.name), default: v.trigger, choices: viewChoices(v), audience: v.audience(relay), on: viewOn(relay, v.name), stored: viewStored(relay, v), last, path: "/view/" + v.name });339 }340 return out;341}342343// viewRowsSince sums the rows the views wrote in a window, for the digest.344export async function viewRowsSince(relay: Relay, since: number): Promise<number> {345 let rows = 0;346 for (const v of VIEWS) for (const r of await viewRuns(relay, v.name)) if (r.at >= since) rows += r.rows;347 return rows;348}349350// ---- publishing: the relay's side, called from the socket handlers and the alarm ----351352export async function viewRuns(relay: Relay, name: string): Promise<ViewRun[]> {353 return (await relay.storage.get<ViewRun[]>("view-runs:" + name)) ?? [];354}355356// publishView folds one stored view and writes its record, or takes the357// record down when the view is off, members-only or empty. The rows the358// fold wrote are recorded so the console and the digest can say what a359// view costs.360export async function publishView(relay: Relay, name: string): Promise<void> {361 const v = viewByName(name);362 if (!v || v.trigger === "live" || relay.settings.policy.owner === "") return;363 await relay.identity.ensure();364 const self = relay.identity.pubkey;365 const before = relay.rowsWritten();366 const takeDown = () => {367 for (const r of relay.sql.exec<{ id: string }>(`SELECT e.id FROM events e JOIN tags t ON t.event_id=e.id WHERE e.kind=30078 AND e.pubkey=? AND t.name='d' AND t.value=?`, self, viewD(name)).toArray()) relay.store.deleteEvent(r.id);368 };369 if (!viewOn(relay, name) || !viewStored(relay, v)) takeDown();370 else {371 const fold = v.fold(relay);372 if (!fold) takeDown();373 else relay.emit([viewEvent(relay, v, fold, true)], now());374 }375 const runs = (await viewRuns(relay, name)).slice(-59);376 runs.push({ at: now(), rows: Math.max(0, relay.rowsWritten() - before) });377 await relay.storage.put("view-runs:" + name, runs);378 if (v.fingerprint) await relay.storage.put("view-fp:" + name, v.fingerprint(relay));379}380381// markView republishes a view set to run on writes shortly, once for a burst.382export function markView(relay: Relay, name: string) {383 if (viewTrigger(relay, name) !== "write" || relay.settings.policy.owner === "") return;384 relay.viewDirty.add(name);385 if (relay.viewTimer) return;386 relay.viewTimer = setTimeout(() => {387 relay.viewTimer = null;388 const names = [...relay.viewDirty];389 relay.viewDirty.clear();390 void (async () => {391 for (const n of names) {392 try {393 await publishView(relay, n);394 } catch (err) {395 console.log("view " + n + " failed: " + (err instanceof Error ? err.message : String(err)));396 }397 }398 })();399 }, 10_000);400}401402// viewsTick runs the scheduled views: daily ones once a day, hourly ones403// once an hour and only when their fingerprint moved or nothing is stored.404export async function viewsTick(relay: Relay, t: number) {405 if (relay.settings.policy.owner === "") return;406 for (const v of VIEWS) {407 const trigger = viewTrigger(relay, v.name);408 if (trigger === "live" || trigger === "off") continue;409 const last = (await relay.storage.get<number>("view-at:" + v.name)) ?? 0;410 const period = trigger === "hourly" ? 3600 : 86400;411 if (t - last < period - 300) continue;412 await relay.storage.put("view-at:" + v.name, t);413 if (v.fingerprint && viewStored(relay, v) && latestStored(relay, v.name) !== null) {414 const fp = v.fingerprint(relay);415 if (fp === (await relay.storage.get<string>("view-fp:" + v.name))) continue;416 }417 await publishView(relay, v.name);418 }419}420421// notePresence records a writer and broadcasts the presence view when the422// throttle allows, otherwise once it does.423export function notePresence(relay: Relay, pubkey?: string) {424 if (pubkey) relay.presenceActive.set(pubkey, now());425 if (!relay.identity.pubkey || !viewOn(relay, "presence") || relay.settings.policy.owner === "") return;426 const wait = relay.presenceAt + PRESENCE_THROTTLE_S - Date.now() / 1000;427 if (wait <= 0) broadcastPresence(relay);428 else if (!relay.presenceTimer) {429 relay.presenceTimer = setTimeout(() => {430 relay.presenceTimer = null;431 broadcastPresence(relay);432 }, wait * 1000);433 }434}435436function broadcastPresence(relay: Relay) {437 relay.presenceAt = Date.now() / 1000;438 relay.broadcast(relay.identity.sign(KIND_PRESENCE, [["d", viewD("presence")], ...presenceTags(relay)], ""));439}