clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

main
bindws / src / card.ts
card.ts 8.3K
1// The relay card: something a name can be linked to from a profile.2//3// GET /card.json the facts: name, owner, members, rules, fuel, naddr, nprofile4// GET /card.nostr the same facts as a kind 30078 signed by the relay key5// GET /card.svg an open graph sized picture with a QR of the naddr6// GET /qr.svg any short text as a QR, for the console7//8// Cards are public and cached for five minutes. Unclaimed and leased relays9// get a smaller card that says so.10import { naddrEncode, nprofileEncode } from "nostr-tools/nip19";11import { svg as qrSVG, encode as qrEncode } from "./qr.ts";12import { escapeHTML } from "./ui.ts";13import type { Relay } from "./relay.ts";14import { whoAsks } from "./auth.ts";15import { KIND_APP_DATA, KIND_GROUP_METADATA } from "./kinds.ts";1617export const CARD_D = "bind.ws/card";18export const QR_MAX_BYTES = 512;19const CACHE = "public, max-age=300";2021export interface Card {22 name: string;23 state: "unclaimed" | "leased" | "claimed";24 url: string; // wss25 console: string;26 claim?: string;27 expires_at?: number;28 description?: string;29 icon?: string;30 owner?: string;31 self?: string;32 members?: number; // only when the directory is public33 reads?: "open" | "auth" | "members";34 writes?: "open" | "allowlist" | "wot" | "owner";35 fuel?: "allowance" | "burning" | "out";36 naddr?: string;37 nprofile?: string; // the owner, with this relay as the hint: "find me here" for feed clients38 signed_url?: string;39 image?: string;40}4142// caller is the pubkeys the request proved, for the directory switch.43export function cardData(relay: Relay, host: string, caller: string[] = []): Card {44 const p = relay.settings.policy;45 const s = relay.settings;46 const url = relay.relayURL(host);47 const web = relay.webURL(host);48 const base = { name: p.name || relay.slug, url, console: web + "/" };49 if (s.isUnclaimed()) return { ...base, state: "unclaimed", claim: web + "/" };50 if (s.isLeased()) return { ...base, state: "leased", expires_at: p.lease?.until, claim: web + "/" };51 const f = relay.fuelStatus();52 const over = f.eventBytes > f.freeEventBytes || f.mediaBytes > f.freeMediaBytes || f.activeMs > f.freeActiveMs || f.rowsWritten > f.freeRowsWritten;53 const self = relay.identity.pubkey;54 const card: Card = {55 ...base,56 state: "claimed",57 description: p.description,58 owner: p.owner,59 reads: p.reads,60 writes: p.writes,61 fuel: f.outOfFuel ? "out" : over ? "burning" : "allowance",62 signed_url: web + "/card.nostr",63 image: web + "/card.svg",64 };65 if (p.icon) card.icon = p.icon;66 card.nprofile = nprofileEncode({ pubkey: p.owner, relays: [url] });67 if (self) {68 card.self = self;69 card.naddr = naddrEncode({ kind: KIND_GROUP_METADATA, pubkey: self, identifier: relay.slug, relays: [url] });70 }71 if (s.mayList(caller) === "") card.members = s.members().length;72 return card;73}7475export function card(relay: Relay, req: Request): Response {76 const url = new URL(req.url);77 const headers = (type: string) => ({ "content-type": type, "cache-control": CACHE, "access-control-allow-origin": "*" });78 const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: headers("application/json") });79 if (url.pathname === "/qr.svg") {80 const text = url.searchParams.get("text") ?? "";81 const bytes = new TextEncoder().encode(text);82 if (bytes.length === 0) return json({ error: "invalid: text is required" }, 400);83 if (bytes.length > QR_MAX_BYTES) return json({ error: `invalid: at most ${QR_MAX_BYTES} bytes` }, 413);84 return new Response(qrSVG(bytes, { margin: 2 }), { headers: headers("image/svg+xml") });85 }86 const who = whoAsks(req, "", null);87 if (typeof who === "string") return json({ error: who }, 401);88 const data = cardData(relay, url.host, who.pubkeys);89 if (url.pathname === "/card.json") return json(data);90 if (url.pathname === "/card.nostr") {91 if (data.state !== "claimed") return json({ error: "restricted: this relay has no owner to sign for it" }, 404);92 return json(relay.identity.sign(KIND_APP_DATA, [["d", CARD_D]], JSON.stringify(data)));93 }94 if (url.pathname === "/card.svg") return new Response(cardSVG(data), { headers: headers("image/svg+xml") });95 return new Response("not found", { status: 404 });96}9798// wrap breaks text into at most `lines` lines of about `width` characters.99function wrap(text: string, width: number, lines: number): string[] {100 const words = text.replace(/\s+/g, " ").trim().split(" ");101 const out: string[] = [];102 let cur = "";103 for (const w of words) {104 if (!w) continue;105 if ((cur + " " + w).trim().length > width) {106 if (cur) out.push(cur);107 cur = w;108 if (out.length === lines) break;109 } else cur = (cur + " " + w).trim();110 }111 if (out.length < lines && cur) out.push(cur);112 if (out.length > lines) out.length = lines;113 if (words.join(" ").length > out.join(" ").length && out.length) out[out.length - 1] = out[out.length - 1].replace(/\s?\S*$/, "") + "…";114 return out;115}116117// cardSVG is 600 by 315, the open graph size, in the console's colours.118export function cardSVG(c: Card): string {119 const ink = "#1c1b18", paper = "#fffdf9", ink2 = "#625e56", forest = "#2f6b45", sun = "#f2d16b", red = "#b8442f";120 const sans = "Instrument Sans, system-ui, sans-serif", mono = "DM Mono, ui-monospace, monospace";121 const e = escapeHTML;122 const title = wrap(c.name, 22, 1)[0] ?? c.name;123 let lines: string[];124 // The facts sit left of the QR plate, so two short lines rather than one long one.125 let facts: string[];126 if (c.state === "unclaimed") {127 lines = ["Nobody owns this relay yet.", "Claim it with one signature."];128 facts = ["unclaimed"];129 } else if (c.state === "leased") {130 const day = c.expires_at ? new Date(c.expires_at * 1000).toISOString().slice(0, 10) : "";131 lines = ["A temporary relay, open to everyone", `until ${day}. Claim it to keep it.`];132 facts = ["temporary"];133 } else {134 lines = wrap(c.description || "A nostr relay on bind.ws.", 46, 2);135 const parts = [136 c.members !== undefined ? `${c.members} ${c.members === 1 ? "member" : "members"}` : "",137 c.writes === "open" ? "anyone writes" : c.writes === "allowlist" ? "members write" : c.writes === "wot" ? "members and follows write" : "owner writes",138 c.reads === "open" ? "anyone reads" : c.reads === "auth" ? "sign in to read" : "members read",139 c.fuel === "out" ? "out of fuel" : c.fuel === "burning" ? "burning sats" : "on free allowance",140 ].filter(Boolean);141 facts = [parts.slice(0, 2).join(" | "), parts.slice(2).join(" | ")].filter(Boolean);142 }143 const qr = c.naddr ? qrEncode(c.naddr) : null;144 let qrSVGInner = "";145 if (qr) {146 const cell = 150 / qr.size;147 let d = "";148 for (let y = 0; y < qr.size; y++) for (let x = 0; x < qr.size; x++) if (qr.modules[y * qr.size + x]) d += `M${(x * cell).toFixed(2)} ${(y * cell).toFixed(2)}h${cell.toFixed(2)}v${cell.toFixed(2)}h-${cell.toFixed(2)}z`;149 qrSVGInner = `<g transform="translate(418 58)"><rect x="-10" y="-10" width="170" height="170" rx="10" fill="#fff" stroke="${ink}" stroke-width="2"/><path d="${d}" fill="${ink}" shape-rendering="crispEdges"/></g><text x="503" y="248" text-anchor="middle" font-family="${mono}" font-size="11" fill="${ink2}">group naddr</text>`;150 }151 const fuelColor = c.fuel === "out" ? red : c.fuel === "burning" ? sun : forest;152 return `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="315" viewBox="0 0 600 315" role="img" aria-label="${e(c.name)} on bind.ws">153<rect width="600" height="315" fill="${paper}"/>154<rect x="6" y="6" width="588" height="303" rx="18" fill="none" stroke="${ink}" stroke-width="3"/>155<circle cx="40" cy="44" r="9" fill="${fuelColor}" stroke="${ink}" stroke-width="2"/>156<text x="58" y="50" font-family="${sans}" font-weight="700" font-size="30" fill="${ink}">${e(title)}</text>157<text x="40" y="96" font-family="${sans}" font-size="17" fill="${ink2}">${e(lines[0] ?? "")}</text>158<text x="40" y="120" font-family="${sans}" font-size="17" fill="${ink2}">${e(lines[1] ?? "")}</text>159<text x="40" y="170" font-family="${mono}" font-size="13" fill="${forest}">${e(facts[0] ?? "")}</text>160<text x="40" y="192" font-family="${mono}" font-size="13" fill="${forest}">${e(facts[1] ?? "")}</text>161<text x="40" y="262" font-family="${mono}" font-size="15" fill="${ink}">${e(c.url)}</text>162<text x="40" y="286" font-family="${sans}" font-size="12" fill="${ink2}">a bind.ws relay</text>163${qrSVGInner}164</svg>`;165}