GET /card.json has name, owner, member count when the directory is public, rules, fuel state and the group naddr; /card.nostr is the same as a kind 30078 signed by the relay key; /card.svg is an open graph sized picture; /qr.svg renders any short text for the console. Unclaimed and leased relays get a smaller card that says so. The naddr comes from nostr-tools' nip19.
- commit
- 580d6b19d5f66705f1f6814891f8eaea10f71ae1
- parent
- ec612cc73beffb48dc8536273606e11570204255
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 158 | ++++++++++++++++++++++++++ |
| M | src/ | 2 | + |
| A | test/ | 179 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,158 @@
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, naddr4+// 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.10+import { naddrEncode } from "nostr-tools/nip19";11+import { svg as qrSVG, encode as qrEncode } from "./qr.ts";12+import { KIND_GROUP_METADATA } from "./identity.ts";13+import { escapeHTML } from "./ui.ts";14+import type { Relay } from "./relay.ts";15+16+export const KIND_APP_DATA = 30078;17+export const CARD_D = "bind.ws/card";18+export const QR_MAX_BYTES = 512;19+const CACHE = "public, max-age=300";20+21+export 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" | "owner";35+ fuel?: "allowance" | "burning" | "out";36+ naddr?: string;37+ signed_url?: string;38+ image?: string;39+}40+41+export function cardData(relay: Relay, host: string): Card {42+ const p = relay.settings.policy;43+ const s = relay.settings;44+ const url = relay.relayURL(host);45+ const web = relay.webURL(host);46+ const base = { name: p.name || relay.slug, url, console: web + "/" };47+ if (s.isUnclaimed()) return { ...base, state: "unclaimed", claim: web + "/" };48+ if (s.isLeased()) return { ...base, state: "leased", expires_at: p.lease?.until, claim: web + "/" };49+ const f = relay.fuelStatus();50+ const over = f.eventBytes > f.freeEventBytes || f.mediaBytes > f.freeMediaBytes || f.activeMs > f.freeActiveMs || f.rowsWritten > f.freeRowsWritten;51+ const self = relay.identity.pubkey;52+ const card: Card = {53+ ...base,54+ state: "claimed",55+ description: p.description,56+ owner: p.owner,57+ reads: p.reads,58+ writes: p.writes,59+ fuel: f.outOfFuel ? "out" : over ? "burning" : "allowance",60+ signed_url: web + "/card.nostr",61+ image: web + "/card.svg",62+ };63+ if (p.icon) card.icon = p.icon;64+ if (self) {65+ card.self = self;66+ card.naddr = naddrEncode({ kind: KIND_GROUP_METADATA, pubkey: self, identifier: relay.slug, relays: [url] });67+ }68+ if (p.directoryPublic) card.members = s.members().length;69+ return card;70+}71+72+export function card(relay: Relay, req: Request): Response {73+ const url = new URL(req.url);74+ const headers = (type: string) => ({ "content-type": type, "cache-control": CACHE, "access-control-allow-origin": "*" });75+ const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: headers("application/json") });76+ if (url.pathname === "/qr.svg") {77+ const text = url.searchParams.get("text") ?? "";78+ const bytes = new TextEncoder().encode(text);79+ if (bytes.length === 0) return json({ error: "invalid: text is required" }, 400);80+ if (bytes.length > QR_MAX_BYTES) return json({ error: `invalid: at most ${QR_MAX_BYTES} bytes` }, 413);81+ return new Response(qrSVG(bytes, { margin: 2 }), { headers: headers("image/svg+xml") });82+ }83+ const data = cardData(relay, url.host);84+ if (url.pathname === "/card.json") return json(data);85+ if (url.pathname === "/card.nostr") {86+ if (data.state !== "claimed") return json({ error: "restricted: this relay has no owner to sign for it" }, 404);87+ return json(relay.identity.sign(KIND_APP_DATA, [["d", CARD_D]], JSON.stringify(data)));88+ }89+ if (url.pathname === "/card.svg") return new Response(cardSVG(data), { headers: headers("image/svg+xml") });90+ return new Response("not found", { status: 404 });91+}92+93+// wrap breaks text into at most `lines` lines of about `width` characters.94+function wrap(text: string, width: number, lines: number): string[] {95+ const words = text.replace(/\s+/g, " ").trim().split(" ");96+ const out: string[] = [];97+ let cur = "";98+ for (const w of words) {99+ if (!w) continue;100+ if ((cur + " " + w).trim().length > width) {101+ if (cur) out.push(cur);102+ cur = w;103+ if (out.length === lines) break;104+ } else cur = (cur + " " + w).trim();105+ }106+ if (out.length < lines && cur) out.push(cur);107+ if (out.length > lines) out.length = lines;108+ if (words.join(" ").length > out.join(" ").length && out.length) out[out.length - 1] = out[out.length - 1].replace(/\s?\S*$/, "") + "…";109+ return out;110+}111+112+// cardSVG is 600 by 315, the open graph size, in the console's colours.113+export function cardSVG(c: Card): string {114+ const ink = "#1c1b18", paper = "#fffdf9", ink2 = "#625e56", forest = "#2f6b45", sun = "#f2d16b", red = "#b8442f";115+ const sans = "Instrument Sans, system-ui, sans-serif", mono = "DM Mono, ui-monospace, monospace";116+ const e = escapeHTML;117+ const title = wrap(c.name, 22, 1)[0] ?? c.name;118+ let lines: string[];119+ let facts: string;120+ if (c.state === "unclaimed") {121+ lines = ["Nobody owns this relay yet.", "Claim it with one signature."];122+ facts = "unclaimed";123+ } else if (c.state === "leased") {124+ const day = c.expires_at ? new Date(c.expires_at * 1000).toISOString().slice(0, 10) : "";125+ lines = ["A temporary relay, open to everyone", `until ${day}. Claim it to keep it.`];126+ facts = "temporary";127+ } else {128+ lines = wrap(c.description || "A nostr relay on bind.ws.", 46, 2);129+ const parts = [130+ c.members !== undefined ? `${c.members} ${c.members === 1 ? "member" : "members"}` : "",131+ c.writes === "open" ? "anyone writes" : c.writes === "allowlist" ? "members write" : "owner writes",132+ c.reads === "open" ? "anyone reads" : c.reads === "auth" ? "sign in to read" : "members read",133+ c.fuel === "out" ? "out of fuel" : c.fuel === "burning" ? "burning sats" : "on free allowance",134+ ].filter(Boolean);135+ facts = parts.join(" | ");136+ }137+ const qr = c.naddr ? qrEncode(c.naddr) : null;138+ let qrSVGInner = "";139+ if (qr) {140+ const cell = 150 / qr.size;141+ let d = "";142+ 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`;143+ 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>`;144+ }145+ const fuelColor = c.fuel === "out" ? red : c.fuel === "burning" ? sun : forest;146+ 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">147+<rect width="600" height="315" fill="${paper}"/>148+<rect x="6" y="6" width="588" height="303" rx="18" fill="none" stroke="${ink}" stroke-width="3"/>149+<circle cx="40" cy="44" r="9" fill="${fuelColor}" stroke="${ink}" stroke-width="2"/>150+<text x="58" y="50" font-family="${sans}" font-weight="700" font-size="30" fill="${ink}">${e(title)}</text>151+<text x="40" y="96" font-family="${sans}" font-size="17" fill="${ink2}">${e(lines[0] ?? "")}</text>152+<text x="40" y="120" font-family="${sans}" font-size="17" fill="${ink2}">${e(lines[1] ?? "")}</text>153+<text x="40" y="176" font-family="${mono}" font-size="13" fill="${forest}">${e(facts)}</text>154+<text x="40" y="262" font-family="${mono}" font-size="15" fill="${ink}">${e(c.url)}</text>155+<text x="40" y="286" font-family="${sans}" font-size="12" fill="${ink2}">a bind.ws relay</text>156+${qrSVGInner}157+</svg>`;158+}@@ -29,6 +29,7 @@ import { KIND_GROUP_MEMBERS } from "./identity.ts";
2929 import { SIGNER_JS } from "./gen/signer.ts";3030 import { isPagePath, pages } from "./pages.ts";3131 import { notify, fuelLow, fuelText } from "./notify.ts";32+import { card } from "./card.ts";3233 import type { Blob } from "./blossom.ts";3334 3435 export interface Env {@@ -678,6 +679,7 @@ export class Relay extends DurableObject<Env> {
678679 return Response.json({ ...this.fuelStatus(), credits: this.fuel.recentCredits() }, { headers: { "access-control-allow-origin": "*" } });679680 }680681 if (url.pathname === "/fuel/invoice" && req.method === "POST") return this.fuelInvoice(req);682+ if (url.pathname === "/card.json" || url.pathname === "/card.nostr" || url.pathname === "/card.svg" || url.pathname === "/qr.svg") return card(this, req);681683 if (req.method === "OPTIONS") {682684 return new Response(null, { headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type, accept", "access-control-allow-methods": "GET, POST, OPTIONS" } });683685 }@@ -0,0 +1,179 @@
1+// The relay card and the QR encoder behind it. The QR test reads a symbol2+// back: format bits, unmasked data, de-interleaved blocks, Reed-Solomon3+// syndromes, and the bytes themselves.4+import { SELF } from "cloudflare:test";5+import { describe, it, expect } from "vitest";6+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";7+import { decode } from "nostr-tools/nip19";8+import { getToken } from "nostr-tools/nip98";9+import { EC_M, MASKS, dataCodewords, dataOrder, encode, formatBits, gfMul, gfPow, layout, capacity } from "../src/qr.ts";10+11+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {12+ const url = `http://${host}/`;13+ const payload = { method, params };14+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);15+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });16+ return { status: resp.status, ...(await resp.json<any>()) };17+}18+const get = (host: string, path: string) => SELF.fetch(`http://${host}${path}`);19+20+// readBack decodes a symbol without any error correction: it must be exact.21+function readBack(sym: ReturnType<typeof encode>): { mask: number; level: number; bytes: Uint8Array } {22+ const { size, modules, version } = sym;23+ const at = (x: number, y: number) => modules[y * size + x];24+ // Format bits around the top-left finder.25+ const fbits: number[] = [];26+ for (let i = 0; i < 6; i++) fbits.push(at(i, 8));27+ fbits.push(at(7, 8), at(8, 8), at(8, 7));28+ for (let i = 9; i < 15; i++) fbits.push(at(8, 14 - i));29+ let f = 0;30+ fbits.forEach((b, i) => (f |= b << i));31+ const mask = (f ^ 0x5412) >> 10 & 7;32+ const level = (f ^ 0x5412) >> 13 & 3;33+ expect(f).toBe(formatBits(mask));34+ // Data modules in placement order, unmasked.35+ const l = layout(version);36+ const order = dataOrder(l);37+ const bits = order.map(([x, y]) => at(x, y) ^ (MASKS[mask](x, y) ? 1 : 0));38+ const [ec, g1, d1, g2, d2] = EC_M[version];39+ const nblocks = g1 + g2;40+ const total = dataCodewords(version) + ec * nblocks;41+ const cw: number[] = [];42+ for (let i = 0; i < total; i++) cw.push(parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2));43+ // De-interleave.44+ const lens = [...Array(g1).fill(d1), ...Array(g2).fill(d2)] as number[];45+ const data: number[][] = lens.map(() => []);46+ const ecs: number[][] = lens.map(() => []);47+ let k = 0;48+ for (let i = 0; i < Math.max(d1, d2); i++) for (let b = 0; b < nblocks; b++) if (i < lens[b]) data[b].push(cw[k++]);49+ for (let i = 0; i < ec; i++) for (let b = 0; b < nblocks; b++) ecs[b].push(cw[k++]);50+ expect(k).toBe(total);51+ // Syndromes: the codeword polynomial vanishes at alpha^0 .. alpha^(ec-1).52+ for (let b = 0; b < nblocks; b++) {53+ const poly = [...data[b], ...ecs[b]];54+ for (let i = 0; i < ec; i++) {55+ let v = 0;56+ const a = gfPow(2, i);57+ for (const c of poly) v = gfMul(v, a) ^ c;58+ expect(v, `block ${b} syndrome ${i}`).toBe(0);59+ }60+ }61+ // Byte mode header and payload.62+ const dbits: number[] = [];63+ for (const block of data) for (const c of block) for (let i = 7; i >= 0; i--) dbits.push((c >> i) & 1);64+ const mode = parseInt(dbits.slice(0, 4).join(""), 2);65+ expect(mode).toBe(4);66+ const cl = version < 10 ? 8 : 16;67+ const len = parseInt(dbits.slice(4, 4 + cl).join(""), 2);68+ const bytes = new Uint8Array(len);69+ for (let i = 0; i < len; i++) bytes[i] = parseInt(dbits.slice(4 + cl + i * 8, 4 + cl + i * 8 + 8).join(""), 2);70+ return { mask, level, bytes };71+}72+73+describe("qr encoder", () => {74+ it("round-trips through every version class with valid format bits and zero syndromes", () => {75+ const cases = [5, 14, 40, 100, 130, 213, 260, 400, 500, 666].map((n) => "x".repeat(n).replace(/x/g, () => String.fromCharCode(33 + Math.floor(Math.random() * 90))));76+ cases.push("HELLO WORLD", "nostrconnect://" + "ab".repeat(32) + "?relay=wss%3A%2F%2Fearly-trout.bind.ws&secret=deadbeef&perms=sign_event%3A27235");77+ const seen = new Set<number>();78+ for (const text of cases) {79+ const sym = encode(text);80+ seen.add(sym.version);81+ expect(sym.size).toBe(sym.version * 4 + 17);82+ // Finder patterns are dark at their corners and cores.83+ for (const [cx, cy] of [[0, 0], [sym.size - 7, 0], [0, sym.size - 7]]) {84+ expect(sym.modules[cy * sym.size + cx]).toBe(1);85+ expect(sym.modules[(cy + 3) * sym.size + cx + 3]).toBe(1);86+ expect(sym.modules[(cy + 1) * sym.size + cx + 1]).toBe(0);87+ }88+ const back = readBack(sym);89+ expect(back.level).toBe(0); // M90+ expect(back.mask).toBe(sym.mask);91+ expect(new TextDecoder().decode(back.bytes)).toBe(text);92+ }93+ expect(seen.size).toBeGreaterThan(6);94+ expect(capacity(1)).toBe(14);95+ expect(capacity(10)).toBe(213);96+ expect(capacity(20)).toBe(666);97+ expect(() => encode("y".repeat(667))).toThrow(/too long/);98+ });99+});100+101+describe("relay card", () => {102+ it("describes a claimed relay, hides the member count when the directory is private, and signs", async () => {103+ const host = "cardy.bind.ws";104+ const owner = generateSecretKey();105+ const member = generateSecretKey();106+ await rpc(host, owner, "claim");107+ await rpc(host, owner, "setmember", getPublicKey(member), { name: "bob" });108+ await rpc(host, owner, "setpolicy", { description: "A relay for people who like cards.", writes: "allowlist" });109+110+ let r = await get(host, "/card.json");111+ expect(r.status).toBe(200);112+ expect(r.headers.get("cache-control")).toContain("max-age=300");113+ let c: any = await r.json();114+ expect(c.state).toBe("claimed");115+ expect(c.name).toBe("cardy");116+ expect(c.url).toBe("wss://cardy.bind.ws");117+ expect(c.console).toBe("https://cardy.bind.ws/");118+ expect(c.owner).toBe(getPublicKey(owner));119+ expect(c.members).toBe(2);120+ expect(c.writes).toBe("allowlist");121+ expect(c.reads).toBe("open");122+ expect(c.fuel).toBe("allowance");123+ expect(c.self).toMatch(/^[0-9a-f]{64}$/);124+ expect(c.signed_url).toBe("https://cardy.bind.ws/card.nostr");125+ const addr = decode(c.naddr);126+ expect(addr.type).toBe("naddr");127+ expect((addr.data as any).kind).toBe(39000);128+ expect((addr.data as any).pubkey).toBe(c.self);129+ expect((addr.data as any).identifier).toBe("cardy");130+ expect((addr.data as any).relays).toEqual(["wss://cardy.bind.ws"]);131+132+ await rpc(host, owner, "setpolicy", { directoryPublic: false });133+ c = await (await get(host, "/card.json")).json();134+ expect(c.members).toBeUndefined();135+136+ const signed: any = await (await get(host, "/card.nostr")).json();137+ expect(signed.kind).toBe(30078);138+ expect(signed.pubkey).toBe(c.self);139+ expect(signed.tags).toEqual([["d", "bind.ws/card"]]);140+ expect(verifyEvent(signed)).toBe(true);141+ expect(JSON.parse(signed.content).naddr).toBe(c.naddr);142+143+ r = await get(host, "/card.svg");144+ expect(r.status).toBe(200);145+ expect(r.headers.get("content-type")).toContain("image/svg+xml");146+ const svg = await r.text();147+ expect(svg).toContain("cardy");148+ expect(svg).toContain("people who like cards");149+ expect(svg).toContain("members write");150+ expect(svg).toContain("group naddr");151+ expect(svg).toContain('<path d="M');152+ });153+154+ it("says so on unclaimed and leased relays, and does not sign for them", async () => {155+ const host = "nobody.bind.ws";156+ const c: any = await (await get(host, "/card.json")).json();157+ expect(c.state).toBe("unclaimed");158+ expect(c.owner).toBeUndefined();159+ expect(c.naddr).toBeUndefined();160+ expect((await get(host, "/card.nostr")).status).toBe(404);161+ const svg = await (await get(host, "/card.svg")).text();162+ expect(svg).toContain("Nobody owns this relay yet");163+ const l: any = await (await SELF.fetch("http://bind.ws/lease", { method: "POST", headers: { "cf-connecting-ip": "10.9.9.9" } })).json();164+ const lc: any = await (await get(`${l.name}.bind.ws`, "/card.json")).json();165+ expect(lc.state).toBe("leased");166+ expect(lc.expires_at).toBe(l.expires_at);167+ });168+169+ it("serves any short text as a QR and caps it", async () => {170+ const host = "nobody.bind.ws";171+ let r = await get(host, "/qr.svg?text=" + encodeURIComponent("nostr:npub1abc"));172+ expect(r.status).toBe(200);173+ expect(r.headers.get("content-type")).toContain("image/svg+xml");174+ expect(await r.text()).toContain("<path");175+ expect((await get(host, "/qr.svg")).status).toBe(400);176+ r = await get(host, "/qr.svg?text=" + "a".repeat(513));177+ expect(r.status).toBe(413);178+ });179+});