clone

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

main
bindws / src / dumps.ts
dumps.ts 5.3K
1// Dumps: the relay's whole event set as JSONL in R2, written by the alarm on2// the schedule the owner picks, kept for a few runs, and downloadable with a3// signature. The way out that needs no other relay.4//5// The file is streamed in pages by sequence into an R2 multipart upload, so6// a large relay never holds its history in memory. Every part but the last7// is exactly PART bytes, which is what R2 requires.8import { verifyNIP98 } from "./auth.ts";9import { can } from "./roles.ts";10import type { Relay } from "./relay.ts";1112export type Dump = { name: string; bytes: number; events: number; at: number };1314const PAGE = 500;15const PART = 8 * 1024 * 1024;16export const DUMP_NAME_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/;1718export function dumpName(t: number): string {19 return new Date(t * 1000).toISOString().slice(0, 10) + ".jsonl";20}21export function dumpKey(slug: string, name: string): string {22 return `${slug}/dumps/${name}`;23}24export function dumpBytes(sql: SqlStorage): number {25 return sql.exec<{ n: number | null }>(`SELECT sum(bytes) AS n FROM dumps`).one().n ?? 0;26}27export function listDumps(sql: SqlStorage): Dump[] {28 return sql.exec<Dump>(`SELECT * FROM dumps ORDER BY at DESC`).toArray();29}3031// dumpDue says whether the schedule owes a dump: daily means one a day,32// weekly one every seven, with an hour of slack so a tick that comes a33// little early still counts.34export function dumpDue(relay: Relay, t: number): boolean {35 const p = relay.settings.policy;36 if (p.dumps === "off" || p.owner === "") return false;37 const last = relay.sql.exec<{ at: number | null }>(`SELECT max(at) AS at FROM dumps`).one().at;38 if (last === null) return true;39 const period = p.dumps === "daily" ? 86400 : 7 * 86400;40 return t - last >= period - 3600;41}4243// writeDump writes today's file, replacing one of the same name, records it44// and drops the oldest beyond the keep count.45export async function writeDump(relay: Relay, t: number): Promise<Dump> {46 const name = dumpName(t);47 const key = dumpKey(relay.slug, name);48 const enc = new TextEncoder();49 const upload = await relay.media.createMultipartUpload(key, { httpMetadata: { contentType: "application/x-ndjson" } });50 const parts: R2UploadedPart[] = [];51 let pending = new Uint8Array(0);52 let total = 0;53 let events = 0;54 let seq = 0;55 const append = (a: Uint8Array, b: Uint8Array) => {56 const out = new Uint8Array(a.length + b.length);57 out.set(a);58 out.set(b, a.length);59 return out;60 };61 try {62 for (;;) {63 const page = relay.store.dumpPage(seq, PAGE);64 if (page.length === 0) break;65 const chunk = enc.encode(page.map((r) => r.raw + "\n").join(""));66 pending = append(pending, chunk);67 events += page.length;68 seq = page[page.length - 1].seq;69 while (pending.length >= PART) {70 parts.push(await upload.uploadPart(parts.length + 1, pending.slice(0, PART)));71 total += PART;72 pending = pending.slice(PART);73 }74 }75 parts.push(await upload.uploadPart(parts.length + 1, pending));76 total += pending.length;77 await upload.complete(parts);78 } catch (err) {79 await upload.abort().catch(() => {});80 throw err;81 }82 relay.sql.exec(`INSERT INTO dumps(name,bytes,events,at) VALUES(?,?,?,?) ON CONFLICT(name) DO UPDATE SET bytes=excluded.bytes, events=excluded.events, at=excluded.at`, name, total, events, t);83 relay.meterBytes(0, total);84 await rotateDumps(relay);85 return { name, bytes: total, events, at: t };86}8788// rotateDumps keeps the newest `dumpsKeep` files.89export async function rotateDumps(relay: Relay) {90 const keep = Math.max(1, relay.settings.policy.dumpsKeep);91 const old = listDumps(relay.sql).slice(keep);92 for (const d of old) await deleteDump(relay, d.name);93}9495export async function deleteDump(relay: Relay, name: string): Promise<boolean> {96 await relay.media.delete(dumpKey(relay.slug, name));97 return relay.sql.exec(`DELETE FROM dumps WHERE name=?`, name).rowsWritten > 0;98}99100// dumpDownload serves GET /dumps/<name> to a NIP-98 signer with the storage101// action. A private relay's history is never a public URL.102export async function dumpDownload(relay: Relay, req: Request): Promise<Response> {103 const json = (b: unknown, status: number) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*" } });104 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");105 if (typeof auth === "string") return json({ error: auth }, 401);106 if (!can(relay.settings.roleOf(auth.pubkey), "storage")) return json({ error: "restricted: not the relay owner" }, 403);107 const name = new URL(req.url).pathname.slice("/dumps/".length);108 if (!DUMP_NAME_RE.test(name)) return json({ error: "invalid: not a dump name" }, 400);109 const row = relay.sql.exec<Dump>(`SELECT * FROM dumps WHERE name=?`, name).toArray()[0];110 const obj = row ? await relay.media.get(dumpKey(relay.slug, name)) : null;111 if (!row || !obj) return json({ error: "not found" }, 404);112 relay.meterBytes(0, obj.size);113 return new Response(obj.body, {114 headers: {115 "content-type": "application/x-ndjson",116 "content-length": String(obj.size),117 "content-disposition": `attachment; filename="${relay.slug}-${name}"`,118 "cache-control": "private, no-store",119 "access-control-allow-origin": "*",120 },121 });122}