clone

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

dependabot/github_actions/actions-9b61906d8b
bindws / src / backups.ts
backups.ts 20.4K
1// Portable relay backups: a bounded JSON archive containing the owner's2// configuration, signed events, Blossom blobs and hosted Git objects. The3// archive is sealed by a manifest hash before it is stored or restored.4import { sha256 } from "@noble/hashes/sha2.js";5import { applyConfig, exportConfig, parseConfig } from "./config.ts";6import { bytesToHex } from "./negentropy.ts";7import { verifyNIP98 } from "./auth.ts";8import { can } from "./roles.ts";9import { expiration, now, validate, type Event } from "./event.ts";10import { type Relay } from "./relay.ts";11import { KIND_PUSH_REGISTRATION } from "./kinds.ts";12import { archiveCurrent, isListKind } from "./list-history.ts";13import { Settings } from "./settings.ts";14import { exportGitBackup, prepareGitBackup, restoreGitBackup, type GitBackup, type PreparedGit } from "./git-backup.ts";15import { DEFAULT_GIT_SQLITE_LIMITS, type GitSqliteLimits } from "./git-sqlite.ts";1617export const BACKUP_FORMAT = "bind.ws/relay-backup/1";18// JSON parsing, base64 expansion and integrity copies coexist in the Worker19// heap. This is an archive/operation budget, independent from retained Git20// capacity; hosts may raise it when their execution/memory budget permits.21export interface BackupLimits { maxBytes: number; maxEntries: number; includeGit?: boolean; gitLimits?: Readonly<GitSqliteLimits>; }22export const DEFAULT_BACKUP_LIMITS: Readonly<BackupLimits> = Object.freeze({ maxBytes: 8 * 1024 * 1024, maxEntries: 12_000, includeGit: true, gitLimits: DEFAULT_GIT_SQLITE_LIMITS });23// Kept as named exports for callers compiled against the original API.24export const BACKUP_MAX_BYTES = DEFAULT_BACKUP_LIMITS.maxBytes;25export const BACKUP_MAX_OBJECTS = DEFAULT_BACKUP_LIMITS.maxEntries;26export const BACKUP_SCHEMA = `CREATE TABLE IF NOT EXISTS backups (id TEXT PRIMARY KEY, bytes INTEGER NOT NULL);`;27export const backupBytes = (relay: Relay): number => relay.sql.exec<{ n: number }>(`SELECT coalesce(sum(bytes),0) n FROM backups`).one().n;28export const BACKUP_ID_RE = /^[a-z0-9][a-z0-9_-]{2,63}$/;2930type Payload = { sha256: string; size: number; data: string };31export type BackupArchive = {32 format: typeof BACKUP_FORMAT;33 manifest: { id: string; slug: string; owner: string; relayIdentity: string; createdAt: number; bytes: number; events: number; blobs: number; git: number; archiveSha256: string };34 config: unknown;35 events: string[];36 blobs: (Payload & { sha256: string; type: string; uploader: string; uploaded: number })[];37 git: (Payload & { key: string })[];38 sqlGit?: GitBackup;39 state?: { listHistory: string[]; hidden: string[]; hosted: string[]; pending: { id: string; until: number }[]; prRefs?: { repo: string; ref: string; until: number }[] };40};4142const enc = new TextEncoder();43const dec = new TextDecoder();44const toB64 = (bytes: Uint8Array) => {45 let out = "";46 for (let i = 0; i < bytes.length; i += 0x8000) out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));47 return btoa(out);48};49const fromB64 = (value: string) => {50 const raw = atob(value);51 const out = new Uint8Array(raw.length);52 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);53 return out;54};55const digest = (bytes: Uint8Array) => bytesToHex(sha256(bytes));56const archiveKey = (relay: Relay, id: string) => `${relay.slug}/backups/${id}.json`;5758const bytesOf = (archive: BackupArchive) => enc.encode(JSON.stringify(archive));59const unsignedBytes = (archive: BackupArchive) => {60 const copy = structuredClone(archive);61 copy.manifest.archiveSha256 = "";62 return enc.encode(JSON.stringify(copy));63};6465// createBackup snapshots the visible database and every tenant-owned media66// object. The cap makes the bounded in-memory archive explicit.67export async function createBackup(relay: Relay, id: string, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<{ manifest: BackupArchive["manifest"]; key: string } | string> {68 if (!BACKUP_ID_RE.test(id)) return "invalid: backup id must be 3 to 64 lowercase letters, digits, dash or underscore";69 if (relay.settings.isUnclaimed() || relay.settings.leaseExpired(now())) return "restricted: relay is not active";70 const owner = relay.settings.policy.owner;71 if (!owner) return "restricted: relay has no owner";72 const events: string[] = [];73 let estimate = 1024;74 let entries = 0;75 const reserve = (bytes: number, count = 1) => {76 // JSON strings and base64 coexist with decoded buffers during sealing.77 const next = estimate + Math.ceil(bytes * 2) + 256;78 if (next > limits.maxBytes || entries + count > limits.maxEntries) return false;79 estimate = next;80 entries += count;81 return true;82 };83 let seq = 0;84 for (;;) {85 const page = relay.store.dumpPage(seq, 1);86 if (!page.length) break;87 for (const x of page) {88 try {89 if ((JSON.parse(x.raw) as Event).kind === KIND_PUSH_REGISTRATION) continue;90 if (!reserve(enc.encode(x.raw).length)) return "restricted: backup exceeds its bounded size or object limit";91 events.push(x.raw);92 } catch { return "error: event serialization failed during backup"; }93 }94 seq = page[page.length - 1].seq;95 }96 const blobs: BackupArchive["blobs"] = [];97 const blobRows = relay.sql.exec<{ sha256: string; size: number; type: string; uploader: string; uploaded: number }>(`SELECT * FROM blobs ORDER BY uploaded LIMIT 12001`).toArray();98 for (const b of blobRows) {99 if (!reserve(b.size)) return "restricted: backup exceeds its bounded size or object limit";100 const obj = await relay.media.get(`${relay.slug}/${b.sha256}`);101 if (!obj) return `error: blob ${b.sha256} disappeared during backup`;102 if (obj.size !== b.size) return "error: blob size changed during backup";103 const data = new Uint8Array(await obj.arrayBuffer());104 if (data.length !== b.size) return `error: blob ${b.sha256} changed during backup`;105 if (data.length !== b.size || digest(data) !== b.sha256) return `error: blob ${b.sha256} failed integrity check`;106 blobs.push({ ...b, data: toB64(data), sha256: b.sha256, size: data.length });107 }108 const git: BackupArchive["git"] = [];109 const sqlGit = limits.includeGit === false ? undefined : exportGitBackup(relay, reserve, limits.gitLimits, limits.maxEntries);110 if (typeof sqlGit === "string") return sqlGit;111 if (sqlGit !== undefined) {112 const verifiedGit = await prepareGitBackup(sqlGit, limits.maxEntries, limits.gitLimits);113 if (typeof verifiedGit === "string") return verifiedGit;114 }115 const config = exportConfig(relay.settings, relay.slug);116 if (!reserve(enc.encode(JSON.stringify(config)).length)) return "restricted: backup exceeds its bounded size or object limit";117 const state = { listHistory: [] as string[], hidden: [...relay.settings.hiddenEvents], hosted: relay.sql.exec<{ id: string }>(`SELECT id FROM grasp_hosted`).toArray().map((r) => r.id), pending: relay.sql.exec<{ id: string; until: number }>(`SELECT id,until FROM grasp_pending`).toArray(), prRefs: relay.sql.exec<{ repo: string; ref: string; until: number }>(`SELECT repo,ref,until FROM grasp_pr_refs`).toArray() };118 for (const row of relay.sql.exec<{ raw: string }>(`SELECT raw FROM list_history WHERE expires=0 OR expires>? ORDER BY saved_at`, now())) {119 if (!reserve(enc.encode(row.raw).length)) return "restricted: backup history exceeds its bounded size";120 state.listHistory.push(row.raw);121 }122 if (!reserve(enc.encode(JSON.stringify({ ...state, listHistory: [] })).length, state.hidden.length + state.hosted.length + state.pending.length + state.prRefs.length)) return "restricted: backup state exceeds its bounded size";123 const archive = { format: BACKUP_FORMAT, manifest: { id, slug: relay.slug, owner, relayIdentity: relay.identity.pubkey, createdAt: now(), bytes: 0, events: events.length, blobs: blobs.length, git: sqlGit?.repositories.reduce((count, repo) => count + repo.objects.length, 0) ?? 0, archiveSha256: "" }, config, events, blobs, git, ...(sqlGit ? { sqlGit } : {}), state } as BackupArchive;124 archive.manifest.archiveSha256 = "0".repeat(64);125 for (let i = 0; i < 4; i++) archive.manifest.bytes = bytesOf(archive).length;126 archive.manifest.archiveSha256 = digest(unsignedBytes(archive));127 const finalBytes = bytesOf(archive);128 if (finalBytes.length > limits.maxBytes) return "restricted: backup exceeds the portable archive size; use includeGit:false with a separate Git mirror or reduce retained data";129 relay.sql.exec(`INSERT OR REPLACE INTO backups(id,bytes) VALUES(?,?)`, id, finalBytes.length);130 await relay.media.put(archiveKey(relay, id), finalBytes, { httpMetadata: { contentType: "application/json" } });131 relay.meterBytes(0, finalBytes.length);132 return { manifest: archive.manifest, key: archiveKey(relay, id) };133}134135export async function listBackups(relay: Relay) {136 const listed = await relay.media.list({ prefix: `${relay.slug}/backups/` });137 return listed.objects.filter((x) => x.key.endsWith(".json")).map((x) => ({ id: x.key.slice(`${relay.slug}/backups/`.length, -5), bytes: x.size }));138}139140export async function deleteBackup(relay: Relay, id: string) {141 if (!BACKUP_ID_RE.test(id)) return false;142 await relay.media.delete(archiveKey(relay, id));143 relay.sql.exec(`DELETE FROM backups WHERE id=?`, id);144 return true;145}146147const preparedArchives = new WeakMap<BackupArchive, PreparedGit[]>();148const checkedArchive = async (bytes: Uint8Array, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<BackupArchive | string> => {149 if (bytes.length > limits.maxBytes) return "restricted: backup exceeds configured archive size";150 let archive: BackupArchive;151 try { archive = JSON.parse(dec.decode(bytes)) as BackupArchive; } catch { return "invalid: backup is not JSON"; }152 if (!archive || archive.format !== BACKUP_FORMAT || !archive.manifest || !Array.isArray(archive.events) || !Array.isArray(archive.blobs) || !Array.isArray(archive.git)) return "invalid: unsupported backup format";153 if (archive.manifest.archiveSha256 !== digest(unsignedBytes(archive))) return "invalid: backup integrity check failed";154 if (archive.events.length + archive.blobs.length + archive.git.length > limits.maxEntries) return "restricted: backup object limit reached";155 for (const raw of archive.events) {156 try { if (typeof raw !== "string" || (validate(JSON.parse(raw) as Event) || JSON.parse(raw).kind === KIND_PUSH_REGISTRATION)) return "invalid: backup contains an invalid event"; }157 catch { return "invalid: backup contains malformed event JSON"; }158 }159 for (const b of archive.blobs) { let data: Uint8Array; try { data = fromB64(b.data); } catch { return "invalid: malformed blob data"; } if (data.length !== b.size || digest(data) !== b.sha256) return "invalid: blob integrity check failed"; }160 if (archive.git.length) return "unsupported: R2 Git archives cannot be restored into SQLite";161 const state = archive.state;162 if (state) {163 if (!Array.isArray(state.listHistory) || !Array.isArray(state.hidden) || !Array.isArray(state.hosted) || !Array.isArray(state.pending)) return "invalid: backup state malformed";164 if (state.prRefs !== undefined && (!Array.isArray(state.prRefs) || state.prRefs.some(r => !r || typeof r.repo !== "string" || !/^(?:pr|30617):[0-9a-f]{64}:.+$/u.test(r.repo) || new TextEncoder().encode(r.repo).length > 327 || typeof r.ref !== "string" || !/^refs\/nostr\/[0-9a-f]{64}$/u.test(r.ref) || !Number.isSafeInteger(r.until) || r.until < 0))) return "invalid: backup PR deadlines malformed";165 if (archive.events.length + archive.blobs.length + archive.git.length + (state.prRefs?.length ?? 0) + state.listHistory.length + state.hidden.length + state.hosted.length + state.pending.length > limits.maxEntries) return "restricted: backup object limit reached";166 const hex = (v: unknown) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);167 if (state.hidden.some((v) => !hex(v)) || state.hosted.some((v) => !hex(v)) || state.pending.some((v) => !v || !hex(v.id) || !Number.isSafeInteger(v.until))) return "invalid: backup state malformed";168 for (const raw of state.listHistory) {169 try { const event = JSON.parse(raw); if (typeof raw !== "string" || validate(event) || !isListKind(event.kind)) return "invalid: backup list history malformed"; }170 catch { return "invalid: backup list history malformed"; }171 }172 }173 const usedEntries = archive.events.length + archive.blobs.length + (state?.prRefs?.length ?? 0) + (state?.listHistory.length ?? 0) + (state?.hidden.length ?? 0) + (state?.hosted.length ?? 0) + (state?.pending.length ?? 0);174 const git = await prepareGitBackup(archive.sqlGit, limits.maxEntries - usedEntries, limits.gitLimits);175 if (typeof git === "string") return git;176 preparedArchives.set(archive, git);177 return archive;178};179180// restoreBackup only accepts an unclaimed, empty target. Events are already181// signed and validated in the archive; direct storage preserves private data182// and avoids applying the target's ordinary write policy to old history.183export async function restoreBackup(relay: Relay, bytes: Uint8Array, caller: string, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<unknown | string> {184 const archive = await checkedArchive(bytes, limits);185 if (typeof archive === "string") return archive;186 if (relay.settings.policy.owner !== "" || relay.settings.isLeased() || relay.sql.exec(`SELECT 1 FROM events LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM blobs LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM git_sqlite_meta UNION ALL SELECT 1 FROM git_sqlite_objects UNION ALL SELECT 1 FROM git_sqlite_catalog LIMIT 1`).toArray().length || (relay.slug !== "" && (await relay.media.list({ prefix: `${relay.slug}/`, limit: 1 })).objects.length)) return "restricted: restore requires a fresh, unclaimed relay";187 if (archive.manifest.owner !== caller) return "restricted: target signer is not the backup owner";188 const config = archive.config as Record<string, unknown>;189 const parsed = parseConfig(config, relay.settings.policy);190 if (typeof parsed === "string") return parsed;191 const staged: string[] = [];192 try {193 for (const b of archive.blobs) {194 const data = fromB64(b.data);195 const key = `${relay.slug}/${b.sha256}`;196 await relay.media.put(key, data, { httpMetadata: { contentType: b.type } }); staged.push(key);197 }198 } catch (error) {199 await relay.media.delete(staged).catch(() => {});200 return "error: restore storage staging failed";201 }202 try {203 relay.storage.transactionSync(() => {204 relay.settings.update({ owner: caller });205 applyConfig(relay.settings, parsed, now());206 for (const raw of archive.events) {207 const e = JSON.parse(raw) as Event;208 const error = relay.store.save(e, now());209 if (error && !error.startsWith("duplicate:")) throw new Error(`error: event restore stopped: ${error}`);210211 }212 for (const raw of archive.state?.listHistory ?? []) {213 const event = JSON.parse(raw) as Event;214 if (!expiration(event) || expiration(event) > now()) archiveCurrent((q, ...args) => relay.sql.exec(q, ...args as SqlStorageValue[]), event, now());215 }216 for (const id of archive.state?.hidden ?? []) relay.settings.setEvent(id, "hide");217 for (const id of archive.state?.hosted ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_hosted(id) SELECT id FROM events WHERE id=?`, id);218 for (const row of archive.state?.pending ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_pending(id,until) SELECT id,? FROM events WHERE id=?`, row.until, row.id);219 for (const row of archive.state?.prRefs ?? []) relay.sql.exec(`INSERT OR IGNORE INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?)`, row.repo, row.ref, row.until);220 for (const b of archive.blobs) relay.sql.exec(`INSERT OR REPLACE INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)`, b.sha256, b.size, b.type, b.uploader, b.uploaded);221 restoreGitBackup(relay, preparedArchives.get(archive)!);222 });223 } catch (error) {224 relay.settings = new Settings(relay.sql);225 relay.settings.load();226 relay.store.hidden = relay.settings.hiddenEvents;227 await relay.media.delete(staged).catch(() => {});228 return error instanceof Error && error.message.startsWith("error:") ? error.message : "error: restore transaction failed";229 }230 await relay.syncSites();231 await relay.publishMembership();232 await relay.publishDiscovery();233 if (relay.sql.exec("SELECT 1 FROM grasp_pending UNION ALL SELECT 1 FROM grasp_pr_refs LIMIT 1").toArray().length || relay.settings.policy.features.grasp02) await relay.ensureAlarm(now() + 1);234 return { restored: true, owner: caller, sourceRelayIdentity: archive.manifest.relayIdentity, targetRelayIdentity: relay.identity.pubkey, events: archive.events.length, blobs: archive.blobs.length, git: archive.manifest.git, bytes: bytes.length };235}236237export async function backupDownload(relay: Relay, req: Request, id: string): Promise<Response> {238 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");239 if (typeof auth === "string") return Response.json({ error: auth }, { status: 401 });240 if (!can(relay.settings.roleOf(auth.pubkey), "storage")) return Response.json({ error: "restricted: not the relay owner" }, { status: 403 });241 if (!BACKUP_ID_RE.test(id)) return Response.json({ error: "invalid: backup id" }, { status: 400 });242 const obj = await relay.media.get(archiveKey(relay, id));243 if (!obj) return Response.json({ error: "not found" }, { status: 404 });244 return new Response(obj.body, { headers: { "content-type": "application/json", "content-length": String(obj.size), "cache-control": "private, no-store", "content-disposition": `attachment; filename="${relay.slug}-${id}.json"` } });245}246247async function readCapped(req: Request, limits: Readonly<BackupLimits> = DEFAULT_BACKUP_LIMITS): Promise<Uint8Array | string> {248 const len = Number(req.headers.get("content-length") ?? 0);249 if (len > limits.maxBytes) return "restricted: backup exceeds configured archive size";250 if (!req.body) return new Uint8Array();251 const reader = req.body.getReader(); const chunks: Uint8Array[] = []; let total = 0;252 for (;;) {253 const part = await reader.read();254 if (part.done) break;255 total += part.value.byteLength;256 if (total > limits.maxBytes) { await reader.cancel(); return "restricted: backup exceeds configured archive size"; }257 chunks.push(part.value);258 }259 const out = new Uint8Array(total); let at = 0;260 for (const chunk of chunks) { out.set(chunk, at); at += chunk.length; }261 return out;262}263264export async function restoreBackupRequest(relay: Relay, req: Request): Promise<Response> {265 const preauth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");266 if (typeof preauth === "string") return Response.json({ error: preauth }, { status: 401 });267 if (relay.settings.policy.owner !== "" || relay.settings.isLeased()) return Response.json({ error: "restricted: restore requires a fresh, unclaimed relay" }, { status: 403 });268 const input = await readCapped(req);269 if (typeof input === "string") return Response.json({ error: input }, { status: 413 });270 const bytes = input;271 const body = new TextDecoder().decode(bytes);272 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, body);273 if (typeof auth === "string") return Response.json({ error: auth }, { status: 401 });274 if (new URL(req.url).pathname === "/backups/preview") {275 const parsed = await checkedArchive(bytes);276 if (typeof parsed === "string") return Response.json({ error: parsed }, { status: 400 });277 const cfg = parseConfig(parsed.config, relay.settings.policy);278 if (typeof cfg === "string") return Response.json({ error: cfg }, { status: 400 });279 if (relay.settings.policy.owner !== "" || relay.settings.isLeased() || relay.sql.exec(`SELECT 1 FROM events LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM blobs LIMIT 1`).toArray().length || relay.sql.exec(`SELECT 1 FROM git_sqlite_meta UNION ALL SELECT 1 FROM git_sqlite_objects UNION ALL SELECT 1 FROM git_sqlite_catalog LIMIT 1`).toArray().length || (relay.slug !== "" && (await relay.media.list({ prefix: `${relay.slug}/`, limit: 1 })).objects.length)) return Response.json({ error: "restricted: restore requires a fresh, unclaimed relay" }, { status: 403 });280 if (parsed.manifest.owner !== auth.pubkey) return Response.json({ error: "restricted: target signer is not the backup owner" }, { status: 403 });281 return Response.json({ result: { preview: true, source: parsed.manifest, targetIsFresh: true, config: parsed.config, events: parsed.events.length, blobs: parsed.blobs.length, git: parsed.manifest.git, bytes: bytes.length } });282 }283 const result = await restoreBackup(relay, bytes, auth.pubkey);284 return typeof result === "string" ? Response.json({ error: result }, { status: result.startsWith("invalid:") ? 400 : 403 }) : Response.json({ result });285}