clone

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

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / src / console / console.js
console.js 106.3K
1(async () => {2 const $ = (s) => document.querySelector(s);3 const $$ = (s) => document.querySelectorAll(s);4 const host = location.host;5 const wsURL = (location.protocol === "https:" ? "wss://" : "ws://") + host;6 const rpcURL = location.origin + "/";7 let info = null, me = null, owner = "", policy = null, fuel = null, people = null, myRole = "";89 const toast = (msg) => { const t = $("#toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove("show"), 2600); };10 // guard wraps a handler: disables the button, shows errors as toasts. Defined first, since handlers below use it.11 const guard = (fn) => async (ev) => { ev.preventDefault(); const b = ev.submitter || ev.target; if (b) b.disabled = true; try { await fn(ev); } catch (e) { toast(e.message); } finally { if (b) b.disabled = false; } };12 const short = (hex) => hex ? hex.slice(0, 8) + "…" + hex.slice(-4) : "";13 // key shows a key short but keeps the whole of it in the DOM: selecting, double-clicking or clicking it copies the full hex.14 const key = (hex) => hex ? '<span class="key" data-short="' + short(hex) + '" title="' + hex + '"><span class="full">' + hex + '</span></span>' : '';15 const hue = (hex) => (parseInt(hex.slice(0, 2), 16) * 360 / 256).toFixed(0) + "deg";16 const av = (hex) => '<i class="av" style="--h:' + hue(hex) + '"></i>';17 const fmtBytes = (n) => n < 1e6 ? (n / 1e3).toFixed(0) + " KB" : n < 1e9 ? (n / 1e6).toFixed(1) + " MB" : (n / 1e9).toFixed(2) + " GB";18 const MiB = 1024 * 1024, GiB = 1024 * MiB;19 const units = (n, divisor) => n == null ? "" : (n / divisor).toFixed(3).replace(/0+$/, "").replace(/\.$/, "");20 const fmtHours = (ms) => ms < 3600e3 ? Math.round(ms / 60e3) + " min" : (ms / 3600e3).toFixed(ms < 36e6 ? 1 : 0) + " h";21 const fuelOver = () => !!fuel && (fuel.eventBytes > fuel.freeEventBytes || fuel.mediaBytes > fuel.freeMediaBytes || fuel.activeMs > fuel.freeActiveMs || fuel.rowsWritten > fuel.freeRowsWritten);22 const fmtTime = (t) => t ? new Date(t * 1000).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "–";23 const fmtDay = (t) => t ? new Date(t * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : "–";24 const ago = (t) => { const s = Math.max(0, Math.floor(Date.now() / 1000) - t); return s < 60 ? "just now" : s < 3600 ? Math.floor(s / 60) + " min" : s < 86400 ? Math.floor(s / 3600) + " h" : Math.floor(s / 86400) + " d"; };25 const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);2627 const IC = {28 x: '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>',29 undo: '<svg viewBox="0 0 24 24"><path d="M4 10h10a5 5 0 0 1 0 10h-3M4 10l4-4M4 10l4 4"/></svg>',30 check: '<svg viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></svg>',31 trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M6 7l1.2 13h9.6L18 7"/></svg>',32 ban: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M6 6l12 12"/></svg>',33 banuser: '<svg viewBox="0 0 24 24"><circle cx="10" cy="8" r="4"/><path d="M3 21a7 7 0 0 1 11-5.7M15 15l6 6M21 15l-6 6"/></svg>',34 eye: '<svg viewBox="0 0 24 24"><path d="M2 12s4-6 10-6 10 6 10 6-4 6-10 6S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>',35 lock: '<svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>',36 pen: '<svg viewBox="0 0 24 24"><path d="M4 20l4-1 11-11-3-3L5 16l-1 4zM13 8l3 3"/></svg>',37 people: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.5"/><circle cx="17" cy="9" r="2.5"/><path d="M3 20a6 6 0 0 1 12 0M15 20a4.5 4.5 0 0 1 7-3.5"/></svg>',38 person: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',39 gauge: '<svg viewBox="0 0 24 24"><path d="M4 16a8 8 0 0 1 16 0M12 16l4-5"/><circle cx="12" cy="16" r="1.2"/></svg>',40 bolt: '<svg viewBox="0 0 24 24"><path d="M13 3L5 14h6l-1 7 8-11h-6z"/></svg>',41 copy: '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>',42 pin: '<svg viewBox="0 0 24 24"><path d="M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6zM12 15v5"/></svg>',43 up: '<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',44 down: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7"/></svg>',45 qr: '<svg viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6"/><rect x="14" y="4" width="6" height="6"/><rect x="4" y="14" width="6" height="6"/><path d="M14 14h3v3M20 14v6h-3M14 20h1"/></svg>',46 // The connection template icons (connections.ts, ICONS): a template names one.47 notes: '<svg viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h3"/></svg>',48 blog: '<svg viewBox="0 0 24 24"><path d="M20 4c-6 0-11 4-13 10l-3 6 6-3c6-2 10-7 10-13z"/><path d="M8 16l6-6"/></svg>',49 bookmark: '<svg viewBox="0 0 24 24"><path d="M7 3h10v18l-5-4-5 4z"/></svg>',50 photo: '<svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="14" rx="2"/><circle cx="12" cy="13" r="3.5"/><path d="M8 6l1.5-2h5L16 6"/></svg>',51 site: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>',52 git: '<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="9" r="2.5"/><path d="M6 8.5v7M18 11.5c0 3-3 4.5-7 4.5H6"/></svg>',53 chat: '<svg viewBox="0 0 24 24"><path d="M4 5h16v10H10l-4 4v-4H4z"/></svg>',54 files: '<svg viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v11H3z"/></svg>',55 key: '<svg viewBox="0 0 24 24"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg>',56 search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="M16 16l4 4"/></svg>',57 feed: '<svg viewBox="0 0 24 24"><path d="M4 4a16 16 0 0 1 16 16M4 10a10 10 0 0 1 10 10"/><circle cx="5.5" cy="18.5" r="1.5"/></svg>',58 app: '<svg viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></svg>',59 };60 const ib = (icon, label, act, id, extra) => '<button class="ib' + (extra ? " " + extra : "") + '" title="' + label + '" aria-label="' + label + '" data-act="' + act + '" data-id="' + esc(id) + '">' + IC[icon] + "</button>";6162 const CH = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";63 function npubToHex(s) {64 s = s.trim().toLowerCase();65 if (/^[0-9a-f]{64}$/.test(s)) return s;66 if (!s.startsWith("npub1")) return null;67 const data = s.slice(5, -6).split("").map((c) => CH.indexOf(c));68 if (data.some((d) => d < 0)) return null;69 let bits = 0, acc = 0, out = [];70 for (const d of data) { acc = (acc << 5) | d; bits += 5; if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }71 return out.length === 32 ? out.map((b) => b.toString(16).padStart(2, "0")).join("") : null;72 }7374 async function sha256hex(s) {75 const b = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));76 return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");77 }78 // ---- signing: a NIP-07 extension, or a NIP-46 remote signer ----79 // The remote path loads the bundled library on first use; its session80 // (client key, signer pubkey, relays) lives in localStorage until sign-out.81 let remote = null, lib = null, pendingNote = null;82 const NO_SIGNER = "Install a nostr extension (Alby, nos2x, …) or connect a remote signer.";83 const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(what + " did not answer; is the signer app open?")), ms))]);84 const signer = {85 ready: () => !!remote || !!window.nostr,86 async getPublicKey() { if (remote) return withTimeout(remote.getPublicKey(), 20000, "The remote signer"); if (window.nostr) return window.nostr.getPublicKey(); throw new Error(NO_SIGNER); },87 async signEvent(ev) { if (remote) return withTimeout(remote.signEvent(ev), 60000, "The remote signer"); if (window.nostr) return window.nostr.signEvent(ev); throw new Error(NO_SIGNER); },88 };89 async function signerLib() {90 if (lib) return lib;91 if (!window.NostrSigner) await new Promise((res, rej) => { const s = document.createElement("script"); s.src = window.SIGNER_URL || "/signer.js"; s.onload = res; s.onerror = () => rej(new Error("Could not load the signer library.")); document.head.appendChild(s); });92 return (lib = window.NostrSigner);93 }94 const onauth = (u) => window.open(u, "_blank");95 // The signer conversation rides this relay, and a relay may ask the socket to96 // authenticate before it delivers kind 24133 to its parties; answer with the97 // session's own key.98 const authPool = (L, sk) => new L.SimplePool({ automaticallyAuth: () => (evt) => Promise.resolve(L.finalizeEvent(evt, sk)) });99 const saveSession = (sk, s, secret) => localStorage.setItem("nip46", JSON.stringify({ sk: lib.bytesToHex(sk), pubkey: s.bp.pubkey, relays: s.bp.relays, secret: secret || null }));100 async function resumeRemote() {101 const raw = localStorage.getItem("nip46");102 if (!raw) return false;103 try {104 const s = JSON.parse(raw); const L = await signerLib();105 remote = L.BunkerSigner.fromBunker(L.hexToBytes(s.sk), { pubkey: s.pubkey, relays: s.relays, secret: s.secret }, { onauth, pool: authPool(L, L.hexToBytes(s.sk)) });106 return true;107 } catch { localStorage.removeItem("nip46"); return false; }108 }109 async function connectBunker(input) {110 const L = await signerLib();111 const bp = await L.parseBunkerInput(input.trim());112 if (!bp) throw new Error("That is not a bunker:// URL.");113 if (!bp.relays.length) throw new Error("The bunker URL names no relay.");114 const sk = L.generateSecretKey();115 const s = L.BunkerSigner.fromBunker(sk, bp, { onauth, pool: authPool(L, sk) });116 await withTimeout(s.connect({ name: host, url: location.origin }), 60000, "The signer");117 await Promise.race([s.switchRelays(), new Promise((r) => setTimeout(r, 3000))]);118 remote = s; saveSession(sk, s, bp.secret);119 }120 // The nostrconnect:// flow: this relay carries the traffic, so no third relay is involved.121 let ncPending = null;122 async function offerNostrConnect() {123 if (ncPending) return ncPending;124 const L = await signerLib();125 const sk = L.generateSecretKey();126 const secret = L.bytesToHex(crypto.getRandomValues(new Uint8Array(8)));127 const uri = L.createNostrConnectURI({ clientPubkey: L.getPublicKey(sk), relays: [wsURL], secret, name: host, url: location.origin, perms: ["sign_event:27235", "sign_event:9734"] });128 $("#nclink").href = uri; $("#ncnote").textContent = "";129 $("#ncqr").src = "/qr.svg?text=" + encodeURIComponent(uri); $("#ncqr").classList.remove("hidden");130 ncPending = L.BunkerSigner.fromURI(sk, uri, { onauth, pool: authPool(L, sk) }, 600000).then((s) => { remote = s; saveSession(sk, s, secret); ncPending = null; return s; }, (e) => { ncPending = null; throw e; });131 return ncPending;132 }133 async function remoteDone() {134 me = await signer.getPublicKey();135 localStorage.setItem("me", me);136 $("#remotesec").classList.add("hidden");137 toast("Remote signer connected");138 if (pendingNote && !owner) { const n = pendingNote; pendingNote = null; await claimNow(n); return; }139 pendingNote = null;140 renderHeader(); await loadAdmin(); await loadPeople();141 }142 function showRemote(note) {143 pendingNote = note || null;144 $("#remotesec").classList.remove("hidden");145 $("#remotesec").scrollIntoView({ behavior: "smooth", block: "start" });146 offerNostrConnect().then(remoteDone).catch((e) => { $("#ncnote").textContent = e.message; });147 }148149 async function rpc(method, ...params) {150 if (!signer.ready()) throw new Error(NO_SIGNER);151 const body = JSON.stringify({ method, params });152 const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", rpcURL], ["method", "POST"], ["payload", await sha256hex(body)]] });153 const resp = await fetch(rpcURL, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: "Nostr " + btoa(JSON.stringify(ev)) }, body });154 const json = await resp.json();155 if (json.error) throw new Error(json.error);156 return json.result;157 }158159 // ---- front of house ----160 // urlish shows a URL as a short link: the host and the start of the path, the whole thing on hover and as the target.161 class Html { constructor(s) { this.s = s; } }162 const urlish = (u) => {163 if (!u) return "";164 let label = u; try { const x = new URL(u); label = x.host + (x.pathname.length > 1 ? x.pathname.slice(0, 18) + (x.pathname.length > 18 ? "\u2026" : "") : ""); } catch { /* not a URL */ }165 if (!/^https?:\/\//.test(u)) return u;166 return new Html('<a href="' + esc(u) + '" target="_blank" rel="noopener" title="' + esc(u) + '">' + esc(label) + "</a>");167 };168 function renderHeader() {169 const name = (info && info.name) || host.split(".")[0];170 $("#title").textContent = name;171 document.title = name + " - relay";172 $("#url").textContent = wsURL;173 $("#d-ws").textContent = wsURL; $("#d-blossom").textContent = location.origin; $("#d-nip05").textContent = "you@" + host;174 $("#nip05-example").textContent = "alice@" + host;175 const desc = (info && info.description) || "";176 $("#desc").textContent = desc; $("#desc").classList.toggle("hidden", !desc);177 // What the owner declared about the relay, on the page, not only in the document clients read.178 const img = (id, url) => { const el = $(id); if (url) el.src = url; el.classList.toggle("hidden", !url); };179 img("#bannerimg", info?.banner); $("#banner").classList.toggle("hidden", !info?.banner);180 img("#iconimg", info?.icon);181 const meta = [];182 for (const t of info?.tags || []) meta.push('<span class="tag">' + esc(t) + "</span>");183 const where = [...(info?.language_tags || []), ...(info?.relay_countries || [])];184 if (where.length) meta.push("<span>" + esc(where.join(", ")) + "</span>");185 const link = (label, href) => href ? '<a href="' + esc(href) + '" target="_blank" rel="noopener">' + label + "</a>" : "";186 const contact = info?.contact ? (/^(mailto:|https?:\/\/)/.test(info.contact) ? link("Contact", info.contact) : "<span>" + esc(info.contact) + "</span>") : "";187 for (const x of [contact, link("Terms", info?.terms_of_service), link("Posting policy", info?.posting_policy), link("Privacy", info?.privacy_policy)]) if (x) meta.push(x);188 $("#metarow").innerHTML = meta.join('<span class="sep">|</span>'); $("#metarow").classList.toggle("hidden", meta.length === 0);189 $("#frontsec").classList.toggle("hidden", !desc && meta.length === 0);190 owner = (info && info.pubkey) || "";191 const isOwner = !!me && me === owner;192 const lease = !owner && info && info.lease ? info.lease : null;193 $("#ownerline").innerHTML = owner ? 'run by <b>' + key(owner) + "</b>" : lease ? "temporary until " + fmtDay(lease.expires_at) : "unclaimed";194 $("#owner-av").classList.toggle("hidden", !owner);195 if (owner) $("#owner-av").style.setProperty("--h", hue(owner));196 $("#who").textContent = me ? (isOwner ? "(that's you)" : "") : "";197 $("#unclaimed").classList.toggle("hidden", !!owner || !!lease);198 $("#leased").classList.toggle("hidden", !lease);199 if (lease) {200 $("#lease-until").textContent = fmtTime(lease.expires_at);201 $("#leasenote").textContent = lease.holder ? "Reserved for the key that asked for it; sign with that key." : "";202 }203 $("#signin").classList.toggle("hidden", !!me || !owner);204 $("#signin46").classList.toggle("hidden", !!me || !owner);205 $("#signout").classList.toggle("hidden", !me);206 $("#about").innerHTML = [207 ["Name", info?.name], ["Description", info?.description], ["Contact", info?.contact], ["Owner", owner ? short(owner) : ""], ["Relay key", info?.self ? short(info.self) : ""], ["Software", urlish(info?.software)], ["Version", info?.version],208 ["Max query", info?.limitation?.max_limit], ["Auth required", String(!!info?.limitation?.auth_required)], ["Restricted writes", String(!!info?.limitation?.restricted_writes)], ["Min PoW", info?.limitation?.min_pow_difficulty || 0],209 ["Tags", info?.tags?.join(", ")], ["Languages", info?.language_tags?.join(", ")], ["Countries", info?.relay_countries?.join(", ")],210 ["Terms", urlish(info?.terms_of_service)], ["Posting policy", urlish(info?.posting_policy)], ["Privacy policy", urlish(info?.privacy_policy)], ["Icon", urlish(info?.icon)], ["Banner", urlish(info?.banner)],211 ["NIPs", info?.supported_nips?.join(" ")],212 ].map(([k, v]) => "<div><small>" + k + "</small><div" + (["Owner", "NIPs", "Relay key"].includes(k) ? ' class="mono"' : "") + ">" + (v === undefined || v === "" ? '<span class="muted">–</span>' : v instanceof Html ? v.s : esc(v)) + "</div></div>").join("");213 renderCare();214 }215216 function renderCare() {217 const g = (icon, label, off) => '<span class="g' + (off ? " off" : "") + '" title="' + label + '"><i>' + IC[icon] + "</i><small>" + label + "</small></span>";218 const writes = policy ? policy.writes : (info?.limitation?.restricted_writes ? "allowlist" : "open");219 const reads = policy ? policy.reads : (info?.limitation?.auth_required ? "auth" : "open");220 let out = writes === "owner" ? g("person", "only owner writes") : writes === "allowlist" ? g("people", "members write") : writes === "wot" ? g("people", "members and follows write") : g("pen", "anyone writes");221 out += reads === "members" ? g("people", "members read") : reads === "auth" ? g("lock", "sign in to read") : g("eye", "anyone reads");222 if (fuel) {223 const over = fuelOver();224 out += fuel.outOfFuel ? g("gauge", "out of fuel", true) : over ? g("bolt", "burning sats") : g("gauge", "on free allowance");225 }226 $("#care").innerHTML = out;227 }228229 async function loadPeople() {230 try { people = await (await fetch("/people")).json(); } catch { return; }231 const isOwner = !!me && me === owner;232 let list = people.people || [];233 if (!people.public && isOwner && policy) list = (window.__members || []);234 $("#peoplesec").classList.toggle("hidden", !owner || list.length === 0);235 $("#people-note").classList.toggle("hidden", people.public);236 $("#people-count").textContent = list.length || "";237 $("#dir").innerHTML = list.map((m) => '<span class="who' + (m.pubkey === me ? " me" : "") + '" title="' + esc(m.pubkey) + '">' + av(m.pubkey) + '<span class="mono">' + (m.name ? esc(m.name) + "@" + host : key(m.pubkey)) + "</span>" + (m.role === "owner" ? '<span class="role">owner</span>' : "") + "</span>").join("");238 }239240 async function loadInfo() {241 info = await (await fetch("/", { headers: { accept: "application/nostr+json" } })).json();242 renderHeader();243 const git = info.supported_grasps?.includes("GRASP-01");244 $("#git-connect").classList.toggle("hidden", !git);245 const clone = "git clone '" + location.origin + "/<npub>/<repo>.git'";246 $("#git-clone").textContent = clone;247 $("#git-copy").dataset.copytext = clone;248 }249250 async function loadFuel() {251 try { fuel = await (await fetch("/fuel")).json(); } catch { return; }252 $("#fuelsec").classList.toggle("hidden", !owner);253 const pct = (used, free) => Math.min(100, Math.round((used / Math.max(free, 1)) * 100));254 const gauge = (id, used, free, text) => { const g = $("#g-" + id), share = free ? used / free : 0; g.style.width = pct(used, free) + "%"; g.classList.toggle("over", share > 1); g.classList.toggle("warm", share > 0.75 && share <= 1); $("#p-" + id).textContent = share > 1 ? "over" : Math.round(share * 100) + "%"; $("#t-" + id).textContent = text; };255 gauge("events", fuel.eventBytes, fuel.freeEventBytes, fmtBytes(fuel.eventBytes) + " of " + fmtBytes(fuel.freeEventBytes) + " free");256 gauge("media", fuel.mediaBytes, fuel.freeMediaBytes, fmtBytes(fuel.mediaBytes) + " of " + fmtBytes(fuel.freeMediaBytes) + " free");257 gauge("active", fuel.activeMs, fuel.freeActiveMs, fmtHours(fuel.activeMs) + " of " + fmtHours(fuel.freeActiveMs) + " free");258 gauge("rows", fuel.rowsWritten, fuel.freeRowsWritten, fuel.rowsWritten.toLocaleString() + " of " + fuel.freeRowsWritten.toLocaleString() + " free");259 const sats = Math.floor(fuel.balanceMsats / 1000);260 const r = fuel.rates;261 $("#fuel-balance").innerHTML = fuel.outOfFuel262 ? '<span class="chip">out of fuel</span> Writes are paused until someone tops up.'263 : "Balance <b>" + sats.toLocaleString() + " sats</b>. Past the allowances, prices track what the hosting costs: " + r.satsPerGBMonthEvents.toLocaleString() + " sats per GB-month of events, " + r.satsPerGBMonthMedia.toLocaleString() + " per GB-month of files, " + r.satsPerActiveHour.toLocaleString() + " per hour awake, " + r.satsPerMillionRows.toLocaleString() + " per million rows written. Traffic is free.";264 $("#topup").classList.toggle("hidden", !fuel.enabled);265 // Who paid is the owner's to see: it comes with the signed stats call.266 let credits = [];267 if (owner && signer.ready()) { try { credits = (await rpc("stats")).credits || []; } catch { credits = []; } }268 $("#credits tbody").innerHTML = credits.length ? credits.map((c) => '<tr><td class="dim">' + esc(fmtTime(c.at)) + '</td><td class="mono" title="' + esc(c.payer) + '">' + av(c.payer) + key(c.payer) + '</td><td class="mono r">' + Math.floor(c.msats / 1000).toLocaleString() + "</td></tr>").join("") : '<tr><td colspan="3" class="muted">no zaps yet</td></tr>';269 if (!fuel.enabled) $("#topup-note").textContent = "Top-ups are not enabled on this service yet.";270 renderCare();271 renderFuelTile();272 }273274 async function topUp(sats) {275 if (!signer.ready()) throw new Error(NO_SIGNER);276 const msats = Math.round(sats * 1000);277 const zapRequest = await signer.signEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: "fuel for " + host, tags: [["p", fuel.servicePubkey], ["amount", String(msats)], ["relays", wsURL]] });278 const r = await (await fetch("/fuel/invoice", { method: "POST", body: JSON.stringify({ zapRequest }) })).json();279 if (r.error) throw new Error(r.error);280 const inv = r.invoice;281 $("#invoice").classList.remove("hidden");282 $("#inv-text").value = inv; $("#inv-link").href = "lightning:" + inv; $("#inv-state").textContent = "waiting for payment…";283 if (window.webln) { try { await window.webln.enable(); await window.webln.sendPayment(inv); } catch (e) { toast(e.message || "wallet declined"); } }284 const before = fuel.creditedMsats;285 for (let i = 0; i < 120; i++) {286 await new Promise((r) => setTimeout(r, 2500));287 await loadFuel();288 if (fuel.creditedMsats > before) { $("#inv-state").textContent = "paid, thank you"; toast("Fuel credited"); return; }289 }290 $("#inv-state").textContent = "no receipt yet; it can take a minute after paying";291 }292293 // ---- console ----294 function showTab(name) {295 if (name === "content" || name === "storage") name = "data";296 const known = myRole === "moderator" ? ["people", "moderation"] : ["people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner"];297 if (!known.includes(name)) name = "people";298 $$(".tabs a").forEach((a) => a.classList.toggle("on", a.dataset.tab === name));299 $$(".panel").forEach((p) => p.classList.toggle("on", p.dataset.panel === name));300 }301 // Tabs switch in place. A fragment still opens a tab when someone arrives with one,302 // but clicking never writes one, and any fragment already there is dropped.303 $$(".tabs a").forEach((a) => a.addEventListener("click", (ev) => { ev.preventDefault(); showTab(a.dataset.tab); if (location.hash) history.replaceState(null, "", location.pathname + location.search); }));304 window.addEventListener("hashchange", () => showTab(location.hash.slice(1)));305 // The strip fades on whichever side has more tabs off screen.306 const tabsFade = () => { const t = $("#tabs"); t.classList.toggle("fade-l", t.scrollLeft > 4); t.classList.toggle("fade-r", t.scrollLeft + t.clientWidth < t.scrollWidth - 4); };307 $("#tabs").addEventListener("scroll", tabsFade); window.addEventListener("resize", tabsFade); new ResizeObserver(tabsFade).observe($("#tabs"));308309 function renderFuelTile() {310 if (!fuel) return;311 const over = fuelOver();312 const sats = Math.max(0, Math.floor(fuel.balanceMsats / 1000));313 const d = new Date(), end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1), days = Math.max(1, Math.ceil((end - Date.now()) / 86400000));314 if (fuel.outOfFuel) { $("#h-fuel").textContent = "0 sats"; $("#h-fuel-label").textContent = "out of fuel"; $("#h-fuel-sub").textContent = "writes are paused"; }315 else if (over) { $("#h-fuel").textContent = sats.toLocaleString() + " sats"; $("#h-fuel-label").textContent = "fuel left"; $("#h-fuel-sub").textContent = "past the free allowance"; }316 else { $("#h-fuel").textContent = days + (days === 1 ? " day" : " days"); $("#h-fuel-label").textContent = "free allowance left"; $("#h-fuel-sub").textContent = sats ? "then " + sats.toLocaleString() + " sats" : "then a top-up is needed"; }317 $("#usage").innerHTML = [["events stored", fmtBytes(fuel.eventBytes)], ["files stored", fmtBytes(fuel.mediaBytes)], ["awake this month", fmtHours(fuel.activeMs)], ["rows written", fuel.rowsWritten.toLocaleString()], ["rows read", fuel.rowsRead.toLocaleString()], ["received this month", fmtBytes(fuel.bytesIn)], ["served this month", fmtBytes(fuel.bytesOut)], ["charged", Math.floor(fuel.chargedMsats / 1000).toLocaleString() + " sats"]]318 .map(([k, v]) => "<div><small>" + k + "</small><b>" + v + "</b></div>").join("");319 }320321 function renderHealth(stats) {322 $("#h-last").textContent = stats.newest ? ago(stats.newest) : "none";323 $("#h-last-sub").textContent = stats.newest ? fmtTime(stats.newest) : "no events yet";324 $("#h-conns").textContent = stats.connections;325 const buckets = [["notes", "k1", [1]], ["reactions", "k2", [7]], ["DMs", "k3", [4, 14, 1059]], ["long-form", "k4", [30023]], ["other", "k5", null]];326 const sums = buckets.map(() => 0); let total = 0;327 for (const { kind, n } of stats.kinds || []) { total += n; const i = buckets.findIndex((b) => b[2] && b[2].includes(kind)); sums[i < 0 ? 4 : i] += n; }328 const parts = buckets.map((b, i) => [b[0], b[1], sums[i]]).filter((p) => p[2] > 0);329 $("#h-kinds").innerHTML = total ? parts.map(([name, cls, n]) => '<i class="' + cls + '" style="flex:' + n + '" title="' + name + " " + Math.round(n * 100 / total) + '%"></i>').join("") : '<i class="k5" style="flex:1"></i>';330 $("#h-kinds-legend").innerHTML = total ? parts.map(([name, cls, n]) => '<span><i class="' + cls + '"></i>' + name + " " + Math.round(n * 100 / total) + "%</span>").join("") : "<span>no events yet</span>";331 renderFuelTile();332 }333334 async function loadAdmin() {335 const isOwner = !!me && me === owner;336 myRole = isOwner ? "owner" : "";337 let stats = null, p = null;338 // A signed-in member may be a moderator; the relay says by answering stats.339 if (me && owner && !isOwner) { try { [stats, p] = await Promise.all([rpc("stats"), rpc("getpolicy")]); myRole = "moderator"; } catch { myRole = ""; } }340 $("#console").classList.toggle("hidden", !myRole);341 loadMine();342 loadConnect();343 $("#console").classList.toggle("mod", myRole === "moderator");344 if (!myRole) { policy = null; renderCare(); return; }345 if (isOwner) [stats, p] = await Promise.all([rpc("stats"), rpc("getpolicy")]);346 policy = p;347 renderHealth(stats);348 renderCare();349 const fa = $("#access"), fi = $("#identity");350 fa.writes.value = p.writes; fa.reads.value = p.reads; fa.openKinds.value = (p.openKinds || []).join(", "); fa.guestReplies.checked = !!p.guestReplies;351 $("#wordsform").words.value = (p.blockedWords || []).join("\n"); $("#wordsform").inTags.checked = !!p.blockedWordsInTags; $("#thresholdform").reportThreshold.value = p.reportThreshold || 0; fa.minPow.value = p.minPow; fa.maxFuture.value = p.maxFuture; fa.maxLimit.value = p.maxLimit; fa.maxSubs.value = p.maxSubs; fa.maxMessageKB.value = p.maxMessageKB;352 fa.eventsPerMinute.value = p.eventsPerMinute; fa.reqsPerMinute.value = p.reqsPerMinute; fa.maxBlobMB.value = p.maxBlobMB;353 const g = p.git || {}, gf = $("#git-capacity");354 if (gf) {355 gf.maxRepositories.value = g.maxRepositories || ""; gf.maxRelayGiB.value = units(g.maxRelayBytes, GiB); gf.maxCompressedGiB.value = units(g.maxCompressedBytes, GiB);356 gf.maxPackMiB.value = units(g.maxPackBytes, MiB); gf.maxObjectMiB.value = units(g.maxObjectBytes, MiB); gf.maxObjects.value = g.maxObjects || "";357 gf.maxRawGiB.value = units(g.maxRawBytes, GiB); gf.maxTransactionRawMiB.value = units(g.maxTransactionRawBytes, MiB); gf.maxTransactionObjects.value = g.maxTransactionObjects || "";358 gf.maxMetadataMiB.value = units(g.maxMetadataBytes, MiB); gf.maxRefs.value = g.maxRefs || ""; gf.maxGraphEdges.value = g.maxGraphEdges || ""; gf.maxFetchGiB.value = units(g.maxFetchBytes, GiB);359 }360 renderFeatures(p.features || {});361 $("#push-policy-form").elements.origins.value = (p.pushCallbacks || []).join("\n");362 $("#push-policy-form").elements.lettered.checked = !!p.letteredNips;363 $("#push-policy-form").elements.delivery.checked = !!p.delivery?.enabled;364 $("#push-policy-form").elements.deliveryMax.value = p.delivery?.maxTargets || 8;365 fi.name.value = p.name; fi.contact.value = p.contact; fi.description.value = p.description; fi.icon.value = p.icon;366 const fj = $("#joinform"); fj.joinTerms.value = p.joinTerms; fj.directoryPublic.checked = !!p.directoryPublic;367 loadCard();368 fi.banner.value = p.banner || ""; fi.postingPolicy.value = p.postingPolicy || ""; fi.privacyPolicy.value = p.privacyPolicy || "";369 fi.tags.value = (p.tags || []).join(", "); fi.languageTags.value = (p.languageTags || []).join(", "); fi.relayCountries.value = (p.relayCountries || []).join(", ");370 const fn = $("#notify"), nt = p.notify || {};371 fn.reports.checked = !!nt.reports; fn.fuel.checked = !!nt.fuel; fn.jobs.checked = !!nt.jobs; fn.succession.checked = !!nt.succession; fn.digest.checked = !!nt.digest;372 showTab(location.hash.slice(1));373 if (isOwner) { await renderPresets(); renderWire(); renderSuccession(); loadDomains(); loadConnectTab(); }374 await Promise.all([loadLists(), loadEvents(true), loadPins(), ...(isOwner ? [loadStorage()] : [])]);375 }376377 async function loadAudit(before) {378 const rows = await rpc("listaudit", before || 0);379 const html = rows.map((r) => '<tr data-seq="' + r.seq + '"><td class="dim">' + esc(fmtTime(r.at)) + '</td><td class="mono" title="' + esc(r.actor) + '">' + av(r.actor) + key(r.actor) + '</td><td><span class="kind">' + esc(r.action) + '</span></td><td class="mono">' + (/^[0-9a-f]{64}$/.test(r.target) ? key(r.target) : esc(r.target)) + '</td><td class="c">' + esc(r.detail) + '</td></tr>').join("");380 const tb = $("#audit tbody");381 if (before) tb.insertAdjacentHTML("beforeend", html); else tb.innerHTML = html || '<tr><td class="dim" colspan="5">Nothing yet</td></tr>';382 $("#audit-more").style.display = rows.length < 100 ? "none" : "";383 }384 $("#audit-more").onclick = guard(async () => { const last = $("#audit tbody tr:last-child"); await loadAudit(last ? +last.dataset.seq : 0); });385 async function loadLists() {386 loadAudit(0);387 const [mem, bans, bannedEvents, allow, block, invites, reports, blobs, blocks, sites] = await Promise.all([rpc("listmembers"), rpc("listbannedpubkeys"), rpc("listbannedevents"), rpc("listallowedkinds"), rpc("listblockedkinds"), rpc("listinvites"), rpc("listreports"), rpc("listblobs", 100), rpc("listblockedips"), rpc("listsites")]);388 const members = mem.members;389 window.__members = members;390 const roleCell = (m) => m.role === "owner" ? ' <span class="chip">owner</span>' : myRole === "owner" ? ' <select class="txt role" title="Role"><option value="member"' + (m.role === "member" ? " selected" : "") + '>member</option><option value="moderator"' + (m.role === "moderator" ? " selected" : "") + '>moderator</option></select>' : m.role === "moderator" ? ' <span class="chip">moderator</span>' : "";391 const untouchable = (m) => m.role === "owner" || (myRole !== "owner" && m.role === "moderator");392 // The tree: everyone under whoever invited them, the owner and the owner's own additions at the root.393 const known = new Set(members.map((m) => m.pubkey));394 const byInviter = new Map();395 for (const m of members) { const k = m.role !== "owner" && known.has(m.invited_by) ? m.invited_by : ""; if (!byInviter.has(k)) byInviter.set(k, []); byInviter.get(k).push(m); }396 const ordered = [], placed = new Set();397 const walk = (k, depth) => { for (const m of byInviter.get(k) || []) { if (placed.has(m.pubkey)) continue; placed.add(m.pubkey); m.depth = depth; ordered.push(m); walk(m.pubkey, depth + 1); } };398 walk("", 0);399 for (const m of members) if (!placed.has(m.pubkey)) { m.depth = 0; ordered.push(m); }400 const nameOf = (pk) => { const x = members.find((m) => m.pubkey === pk); return x && x.name ? x.name : short(pk); };401 const limits = (m) => m.role === "owner" ? "" : myRole === "owner"402 ? '<input class="txt num keep" type="number" min="0" placeholder="days" title="Keep this member\'s events for this many days; blank follows the relay rules" value="' + (m.keep_days || "") + '"><input class="txt num cap" type="number" min="0" placeholder="KB" title="Cap on stored bytes, in KB; blank is unlimited" value="' + (m.max_bytes ? Math.round(m.max_bytes / 1024) : "") + '">'403 : '<span class="dim">' + (m.keep_days ? m.keep_days + " d" : "") + (m.max_bytes ? " " + fmtBytes(m.max_bytes) : "") + "</span>";404 $("#members tbody").innerHTML = ordered.map((m) => '<tr data-pk="' + m.pubkey + '"><td class="mono" title="' + esc(m.pubkey) + '" style="padding-left:' + (0.5 + m.depth * 1.1) + 'rem">' + av(m.pubkey) + key(m.pubkey) + roleCell(m) + (m.depth ? ' <span class="dim">via ' + esc(nameOf(m.invited_by)) + "</span>" : "") + (m.invites ? ' <span class="chip" title="live invites">' + m.invites + " inv</span>" : "") + '</td><td class="name"><input class="txt name" value="' + esc(m.name || "") + '" placeholder="name" pattern="[a-z0-9._-]{1,64}"></td><td><input class="txt note" value="' + esc(m.note || "") + '" placeholder="note"></td><td class="lim">' + limits(m) + '</td><td class="dim">' + esc(fmtDay(m.joined_at)) + ", " + esc(m.via) + '</td><td class="r">' + ib("check", "Save", "savemember", m.pubkey) + (untouchable(m) ? "" : ib("x", "Remove", "removemember", m.pubkey) + ib("banuser", "Ban", "banpubkey", m.pubkey, "danger")) + "</td></tr>").join("");405 const tf = $("#treeform");406 tf.classList.toggle("hidden", myRole !== "owner");407 if (policy && policy.memberInvites) { tf.depth.value = policy.memberInvites.depth; tf.quota.value = policy.memberInvites.quota; }408 $("#transfer [name=pubkey]").innerHTML = members.filter((m) => m.role !== "owner").map((m) => '<option value="' + m.pubkey + '">' + esc(m.name ? m.name + "@" + host : key(m.pubkey)) + "</option>").join("");409 $("#succession [name=heir]").innerHTML = $("#transfer [name=pubkey]").innerHTML;410 $("#tc-people").textContent = members.length;411 const link = (code) => location.origin + "/invite/" + code;412 $("#invites").innerHTML = invites.length ? invites.map((i) => '<li><i class="ev">inv</i><span><a class="mono" href="/invite/' + i.code + '">…' + i.code.slice(-8) + "</a> " + esc(i.note || "") + ' <span class="dim">' + i.uses + (i.max_uses ? "/" + i.max_uses : "") + " used, until " + esc(fmtDay(i.expires_at)) + "</span></span><span>" + ib("copy", "Copy link", "copy", link(i.code)) + ib("x", "Revoke", "revokeinvite", i.code) + "</span></li>").join("") : '<li class="empty">no invites</li>';413 const person = (r, icon, label, act) => "<li>" + av(r.pubkey) + '<span><b class="mono" title="' + esc(r.pubkey) + '">' + short(r.pubkey) + "</b> " + esc(r.reason || "") + "</span>" + ib(icon, label, act, r.pubkey) + "</li>";414 $("#bans").innerHTML = bans.length ? bans.map((r) => person(r, "undo", "Unban", "unrulepubkey")).join("") : '<li class="empty">nobody banned</li>';415 $("#blocks").innerHTML = blocks.length ? blocks.map((r) => '<li><i class="ev">ip</i><span><b class="mono">' + esc(r.ip) + "</b> " + esc(r.reason || "") + "</span>" + ib("undo", "Unblock", "unblockip", r.ip) + "</li>").join("") : '<li class="empty">no addresses blocked</li>';416 $("#banned-events").innerHTML = bannedEvents.map((r) => '<li><i class="ev">ev</i><span><b class="mono" title="' + esc(r.id) + '">' + key(r.id) + "</b> " + esc(r.reason || "") + "</span>" + ib("check", "Allow again", "allowevent", r.id) + "</li>").join("");417 $("#reports tbody").innerHTML = reports.length ? reports.map((r) => '<tr><td class="dim">' + esc(fmtTime(r.at)) + '</td><td><span class="kind">' + esc(r.type || "report") + '</span></td><td class="mono" title="' + esc(r.target_pubkey) + '">' + av(r.target_pubkey) + key(r.target_pubkey) + (r.target_event ? (r.blob ? ' <a class="dim" href="/' + esc(r.target_event) + '" target="_blank" rel="noopener">file ' + key(r.target_event) + "</a>" : ' <span class="dim">ev ' + key(r.target_event) + (r.hidden ? ' <span class="tag">hidden</span>' : "") + "</span>") : "") + '</td><td class="c">' + esc(r.content) + '</td><td class="r">' + ib("check", "Dismiss", "resolve:dismiss", r.id) + ib("trash", r.blob ? "Delete the file" : "Delete the event", "resolve:delete", r.id) + ib("banuser", "Ban the author", "resolve:ban", r.id, "danger") + "</td></tr>").join("") : '<tr><td colspan="5" class="muted">nothing reported</td></tr>';418 $("#reports-count").textContent = reports.length || ""; $("#tc-reports").textContent = reports.length || "";419 $("#blobs tbody").innerHTML = blobs.length ? blobs.map((b) => '<tr><td class="dim">' + esc(fmtTime(b.uploaded)) + '</td><td class="mono"><a href="' + esc(b.url) + '" target="_blank" rel="noopener">' + key(b.sha256) + '</a> <span class="dim">' + esc(b.type) + '</span></td><td class="mono">' + fmtBytes(b.size) + '</td><td class="mono" title="' + esc(b.uploader) + '">' + av(b.uploader) + key(b.uploader) + '</td><td class="r">' + ib("trash", "Delete file", "deleteblob", b.sha256, "danger") + "</td></tr>").join("") : '<tr><td colspan="5" class="muted">no uploads</td></tr>';420 $("#blobs-count").textContent = blobs.length || "";421 $("#sites tbody").innerHTML = sites.length ? sites.map((s) => '<tr><td class="mono" title="' + esc(s.author) + '">' + av(s.author) + key(s.author) + '</td><td class="mono">' + esc(s.d || (s.kind === 5128 ? "snapshot" : "root")) + '</td><td><a href="' + esc(s.url) + '" target="_blank" rel="noopener" title="' + esc(s.url) + '">' + esc(s.url) + '</a></td><td class="r mono">' + esc(s.paths) + (s.missing ? ' <span class="chip bad" title="files still being mirrored">' + esc(s.missing) + ' missing</span>' : '') + '</td><td class="r mono">' + fmtBytes(s.size) + '</td><td class="dim">' + esc(s.expires_at ? fmtTime(s.expires_at) : "never") + '</td><td class="r">' + ib("trash", "Delete site", "deleteevent", s.id, "danger") + '</td></tr>').join("") : '<tr><td colspan="7" class="muted">no sites yet</td></tr>';422 $("#sites-count").textContent = sites.length || "";423 const tag = (k, cls) => '<span class="tag ' + cls + '">' + k + ib("x", "Remove rule", "unrulekind", String(k)) + "</span>";424 $("#kinds-allow").innerHTML = allow.length ? allow.map((k) => tag(k, "ok")).join("") : '<span class="tag plain">all</span>';425 $("#kinds-block").innerHTML = block.length ? block.map((k) => tag(k, "blk")).join("") : '<span class="tag plain">none</span>';426 await loadPeople();427 }428429 const KIND_NAMES = { 0: "profiles", 1: "notes", 3: "contacts", 4: "DMs", 5: "deletions", 6: "reposts", 7: "reactions", 16: "reposts", 1059: "gift wraps", 1063: "file headers", 1111: "comments", 1984: "reports", 9734: "zap requests", 9735: "zap receipts", 9802: "highlights", 10002: "relay lists", 13534: "roster", 9000: "group adds", 9001: "group removals", 9021: "join requests", 9022: "leave requests", 39000: "group info", 39001: "group admins", 39002: "group members", 39003: "group roles", 30023: "articles", 30024: "drafts", 30078: "app data", 30311: "live events", 30818: "wiki" };430 const kindName = (k) => KIND_NAMES[k] || (k >= 20000 && k < 30000 ? "ephemeral" : k >= 30000 && k < 40000 ? "addressable" : k >= 10000 && k < 20000 ? "replaceable" : "kind " + k);431 const SYS_KINDS = new Set([0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 39000, 39001, 39002, 39003]);432 let storage = null;433434 // Features (settings.ts): a select each; search has three modes, the rest on or off.435 const FEATURES = [436 ["search", "Search", "NIP-50. Prose indexes notes, threads, comments, highlights, articles and wiki pages; full indexes every public kind with content. A change applies to events from then on.", ["prose:prose", "full:full", "off:off"]],437 ["sync", "Sync", "NIP-77 reconciliation, which reads the whole matching set per sync."],438 ["count", "Counts", "NIP-45 COUNT, with HLL sketches."],439 ["discovery", "Discovery record", "NIP-66: the record the relay signs about itself, for crawlers."],440 ["names", "Names", "NIP-05 addresses under this relay's domain."],441 ["files", "Files", "Blossom and NIP-96: uploads, downloads and listings."],442 ["pages", "Pages and feed", "Notes and articles as pages, and the Atom feed."],443 ["sites", "Static websites", "NIP-5A sites on their own hostnames. Mirroring copies missing files into this relay and costs fuel.", ["mirror:on, mirror files", "proxy:on, fetch as needed", "off:off"]],444 ["marmot", "Marmot transport", "Signed KeyPackages and encrypted group messages, with account admission for ephemeral authors."],445 ["grasp", "Git repositories", "GRASP Git hosting with admitted repository state. The prototype backend has bounded storage and compute limits."],446 ["push", "Relay push", "NIP-9a callback delivery for members and the owner. Requires approved callback origins; advertises lettered NIP identifiers."],447 ["signer", "Signer traffic", "NIP-46 remote signing carried for anyone, never stored."],448 ];449 function renderFeatures(f) {450 $("#features").innerHTML = FEATURES.map(([k, title, about, modes]) => {451 const cur = k === "sites" ? (f.sites?.enabled === false ? "off" : f.sites?.mirror === false ? "proxy" : "mirror") : modes ? String(f[k] || "prose") : String(f[k] !== false);452 const opts = (modes || ["true:on", "false:off"]).map((m) => { const [v, l] = m.split(":"); return '<option value="' + v + '"' + (cur === v ? " selected" : "") + ">" + l + "</option>"; }).join("");453 return "<label><span>" + esc(title) + " <small>" + esc(about) + "</small></span><select class=\"txt\" data-feature=\"" + k + "\">" + opts + "</select></label>";454 }).join("");455 }456 $("#features").addEventListener("change", guard(async (ev) => {457 const sel = ev.target.closest("select[data-feature]"); if (!sel) return;458 const k = sel.dataset.feature, v = k === "search" || k === "sites" ? sel.value : sel.value === "true";459 policy = await rpc("setpolicy", { features: { [k]: k === "sites" ? { enabled: v !== "off", mirror: v === "mirror" } : v } });460 toast(k === "search" ? "Search: " + v : (v ? "Switched on " : "Switched off ") + k); await loadInfo();461 }));462463 $("#push-policy-form").addEventListener("submit", guard(async (ev) => {464 ev.preventDefault();465 const form = ev.target;466 const origins = form.elements.origins.value.split(/\s+/).filter(Boolean);467 const updated = await rpc("setpolicy", { pushCallbacks: origins, letteredNips: form.elements.lettered.checked, delivery: { enabled: form.elements.delivery.checked, maxTargets: Math.max(1, Math.min(16, Math.floor(+form.elements.deliveryMax.value || 8))) } });468 if (JSON.stringify(updated.pushCallbacks) !== JSON.stringify([...new Set(origins.map((s) => s.replace(/\/$/, "")))])) throw new Error("Use up to sixteen exact HTTPS origins, with no path or credentials.");469 policy = updated;470 toast("Saved delivery policy"); await loadInfo();471 }));472473 async function loadViews() {474 let views;475 try { views = await rpc("listviews"); } catch { return; }476 $("#views").innerHTML = views.map((v) => {477 const runs = v.trigger === "off" ? "off" : v.trigger === "live" ? "live, from memory" : v.trigger === "write" ? "on write and daily" : v.trigger;478 const label = (c) => (c === "off" ? "off" : c === "write" ? "on write" : c === "live" ? "on" : c);479 const choices = [...v.choices, v.default].filter((c, i, a) => a.indexOf(c) === i);480 const pick = '<select class="txt" data-view="' + esc(v.name) + '" style="width:auto">' + choices.map((c) => '<option value="' + c + '"' + (c === v.trigger ? " selected" : "") + ">" + label(c) + "</option>").join("") + "</select>";481 const who = v.audience === "members" ? "members" + (v.stored ? "" : ", on request") : "anyone";482 const last = v.trigger === "live" ? "" : v.last ? "last run " + fmtTime(v.last.at) : "not run yet";483 const rows = v.trigger === "live" ? "no rows" : v.last ? v.last.rows.toLocaleString() + " rows" : "";484 const meta = [runs, who, last, rows].filter(Boolean).map((x) => "<span>" + esc(x) + "</span>").join('<span class="sep">|</span>');485 return '<div class="wire-row' + (v.on ? "" : " dim") + '"><div class="wire-main"><b>' + esc(v.name) + '</b><br><small>' + esc(v.about) + '</small></div><div class="wire-side"><div class="wire-acts">' + pick + (v.on ? '<a class="btn" href="' + esc(v.path) + '" target="_blank" rel="noopener">Open</a>' : "") + '</div><div class="wire-meta">' + meta + "</div></div></div>";486 }).join("");487 }488 $("#views").addEventListener("change", guard(async (ev) => {489 const sel = ev.target.closest("select[data-view]"); if (!sel) return;490 const value = sel.value === "live" ? true : sel.value;491 await rpc("setpolicy", { views: { [sel.dataset.view]: value } });492 toast(sel.value === "off" ? "Took down " + sel.dataset.view : sel.dataset.view + ": " + (sel.value === "live" ? "on" : sel.value === "write" ? "on write" : sel.value)); await loadViews(); await loadInfo();493 }));494 async function loadDumps() {495 const list = await rpc("listdumps");496 const f = $("#dumpform");497 if (policy) { f.dumps.value = policy.dumps || "off"; f.keep.value = policy.dumpsKeep || 7; }498 $("#dumps-count").textContent = list.length || "";499 $("#dumps").innerHTML = list.length ? list.map((d) => '<li><i class="ev">jsonl</i><span><b class="mono">' + esc(d.name) + '</b> <span class="dim">' + d.events.toLocaleString() + " events, " + fmtBytes(d.bytes) + "</span></span><span>" + ib("copy", "Download", "downloaddump", d.name) + ib("trash", "Delete", "deletedump", d.name, "danger") + "</span></li>").join("") : '<li class="empty">no dumps yet</li>';500 }501 async function loadBackups() {502 const list = await rpc("listbackups");503 $("#backups-count").textContent = list.length || "";504 $("#backups").innerHTML = list.length ? list.map((b) => '<li><i class="ev">backup</i><span><b class="mono">' + esc(b.id) + '</b> <span class="dim">' + fmtBytes(b.bytes) + '</span></span><span>' + ib("copy", "Download", "downloadbackup", b.id) + ib("trash", "Delete", "deletebackup", b.id, "danger") + '</span></li>').join("") : '<li class="empty">no backups yet</li>';505 }506 // A dump is fetched with a signed request and handed to the browser as a file.507 async function downloadDump(name) {508 if (!signer.ready()) throw new Error(NO_SIGNER);509 const url = location.origin + "/dumps/" + name;510 const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", url], ["method", "GET"]] });511 const resp = await fetch(url, { headers: { authorization: "Nostr " + btoa(JSON.stringify(ev)) } });512 if (!resp.ok) throw new Error((await resp.json()).error || "download failed");513 const a = document.createElement("a");514 a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + name; a.click(); URL.revokeObjectURL(a.href);515 }516 async function downloadBackup(id) {517 const resp = await backupRequest("/backups/" + encodeURIComponent(id), "GET");518 if (!resp.ok) throw new Error("backup download failed");519 const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + id + ".json"; a.click(); URL.revokeObjectURL(a.href);520 }521 // A plain member's own invites, when the owner lets members invite.522 async function loadMine() {523 const sec = $("#myinvites");524 if (!me || !owner || myRole || !signer.ready()) { sec.classList.add("hidden"); return; }525 let mine;526 try { mine = await rpc("listinvites"); } catch { sec.classList.add("hidden"); return; }527 sec.classList.remove("hidden");528 const link = (code) => location.origin + "/invite/" + code;529 $("#mine").innerHTML = mine.length ? mine.map((i) => '<li><i class="ev">inv</i><span><a class="mono" href="/invite/' + i.code + '">…' + i.code.slice(-8) + '</a> <span class="dim">' + i.uses + (i.max_uses ? "/" + i.max_uses : "") + " used, until " + esc(fmtDay(i.expires_at)) + "</span></span><span>" + ib("copy", "Copy link", "copy", link(i.code)) + ib("x", "Revoke", "revokeinvite", i.code) + "</span></li>").join("") : '<li class="empty">no invites yet</li>';530 }531 async function loadStorage() {532 storage = await rpc("storagestats");533 pollJobs();534 loadViews();535 loadDumps().catch(() => {});536 loadBackups().catch(() => {});537 const st = storage;538 const top = st.kinds.slice(0, 4), rest = st.kinds.slice(4).reduce((a, k) => a + k.bytes, 0);539 const parts = [...top.map((k, i) => [k.kind + " " + kindName(k.kind), "k" + (i + 1), k.bytes]), ...(rest ? [["other", "k5", rest]] : [])];540 const total = st.eventBytes || 1;541 $("#s-bar").innerHTML = parts.length ? parts.map(([n, c, b]) => '<i class="' + c + '" style="flex:' + b + '" title="' + esc(n) + " " + fmtBytes(b) + '"></i>').join("") : '<i class="k5" style="flex:1"></i>';542 $("#s-legend").innerHTML = parts.length ? parts.map(([n, c, b]) => '<span><i class="' + c + '"></i>' + esc(n) + " " + Math.round(b * 100 / total) + "%</span>").join("") : "<span>no events yet</span>";543 const overhead = Math.max(0, st.databaseBytes - st.eventBytes);544 $("#s-totals").innerHTML = [["events", st.events.toLocaleString() + " <small>" + fmtBytes(st.eventBytes) + "</small>"], ["index and overhead", fmtBytes(overhead)], ["files", st.blobs.toLocaleString() + " <small>" + fmtBytes(st.mediaBytes) + "</small>"]]545 .map(([k, v]) => "<div><small>" + k + "</small><b>" + v + "</b></div>").join("");546 const any = st.retention.find((r) => r.kind === null);547 const own = (kind) => { const r = st.retention.find((x) => x.kind === kind); return r ? r.days : ""; };548 const row = (k) => {549 const key = k.kind === null ? "" : String(k.kind);550 const placeholder = k.kind === null ? "forever" : k.replaceable || !any ? "forever" : any.days + " (everything else)";551 const pill = k.kind === null ? "everything else" : '<span class="kind' + (k.protected ? ' sys" title="required: the relay depends on this kind' : "") + '">' + k.kind + "</span> " + esc(kindName(k.kind));552 const cells = k.protected553 ? '<td class="keep sys" colspan="2" title="The relay depends on this kind, so it is never expired or purged.">required</td>'554 : '<td class="keep"><input class="txt num days" type="number" min="0" max="36500" placeholder="' + placeholder + '" value="' + own(k.kind) + '"> days</td><td class="r">' + ib("check", "Save keep-for rule", "saveretention", key) + ib("trash", "Purge", "purgekind", key, "danger") + "</td>";555 return '<tr class="' + (k.kind === null ? "any" : "") + '" data-kind="' + key + '"><td>' + pill + '</td><td class="r mono">' + (k.n === undefined ? "" : k.n.toLocaleString()) + '</td><td class="r mono">' + (k.bytes === undefined ? "" : fmtBytes(k.bytes)) + '</td><td class="dim">' + (k.oldest ? esc(fmtDay(k.oldest)) : "") + "</td>" + cells + "</tr>";556 };557 $("#kinds tbody").innerHTML = [...st.kinds, { kind: null, days: any ? any.days : 0 }].map(row).join("");558 loadListHistory().catch(() => {});559 }560561 async function loadListHistory() {562 const rows = await rpc("listlisthistory");563 const labels = { 3: "follows", 10002: "relay list", 10003: "bookmarks", 30003: "bookmark list" };564 $("#listhistory tbody").innerHTML = rows.length ? rows.map((r) => '<tr><td>' + esc((labels[r.kind] || ("kind " + r.kind)) + (r.d ? " / " + r.d : "")) + '</td><td class="dim">' + esc(fmtTime(r.created_at)) + '</td><td class="dim">' + esc(fmtTime(r.saved_at)) + '</td><td class="r">' + ib("undo", "Restore this version", "restorelist", r.event_id) + '</td></tr>').join("") : '<tr><td colspan="4" class="muted">no older list versions yet</td></tr>';565 }566567 let searchQuery = "";568 async function loadEvents(reset) {569 const list = searchQuery ? await rpc("searchevents", searchQuery, 200) : await rpc("listrecentevents", reset ? 50 : 200);570 $("#events tbody").innerHTML = list.length ? list.map((e) => '<tr><td class="dim">' + esc(fmtTime(e.created_at)) + '</td><td><span class="kind' + (SYS_KINDS.has(e.kind) ? ' sys" title="required: the relay depends on this kind' : "") + '">' + e.kind + '</span></td><td class="mono" title="' + e.pubkey + '">' + av(e.pubkey) + key(e.pubkey) + '</td><td class="c">' + esc(e.content) + '</td><td class="r">' + ib("pin", "Pin", "pinevent", e.id) + ib("trash", "Delete event", "deleteevent", e.id) + ib("ban", "Ban event", "banevent", e.id, "danger") + ib("banuser", "Ban author", "banpubkey", e.pubkey, "danger") + "</td></tr>").join("") : '<tr><td colspan="5" class="muted">no events yet</td></tr>';571 $("#more").classList.toggle("hidden", searchQuery || list.length < 50);572 }573 $("#searchform").onsubmit = guard(async (ev) => {574 searchQuery = ev.target.q.value.trim();575 $("#searchclear").classList.toggle("hidden", !searchQuery);576 await loadEvents(true);577 });578 $("#searchclear").onclick = guard(async () => {579 searchQuery = ""; $("#searchform").reset(); $("#searchclear").classList.add("hidden");580 await loadEvents(true);581 });582 async function loadPins() {583 const pins = await rpc("listpins");584 $("#pins-count").textContent = pins.length || "";585 $("#pins").innerHTML = pins.length ? pins.map((t) => '<li><i class="ev">' + (t[0] === "e" ? "event" : "address") + '</i><span class="mono">' + esc(t[1].length > 40 ? t[1].slice(0, 16) + "\u2026" + t[1].slice(-8) : t[1]) + "</span><span>" + ib("copy", "Copy", "copy", t[1]) + ib("x", "Unpin", "unpinevent", t[1]) + "</span></li>").join("") : '<li class="muted">nothing pinned</li>';586 }587 $("#pinform").onsubmit = guard(async (ev) => {588 await rpc("pinevent", ev.target.id.value.trim()); ev.target.reset(); toast("Pinned"); await loadPins();589 });590591 // ---- actions ----592 const refresh = () => Promise.all([loadLists(), loadEvents(true), loadStorage(), loadPins()]);593594 $("#copy").onclick = async () => { await navigator.clipboard.writeText(wsURL); toast("copied " + wsURL); };595 $("#signin").onclick = guard(async () => {596 if (!window.nostr) throw new Error("No nostr extension found. Install one (Alby, nos2x, …) and reload, or use a remote signer.");597 me = await window.nostr.getPublicKey();598 localStorage.setItem("me", me);599 renderHeader(); await loadAdmin(); await loadPeople();600 });601 $("#signin46").onclick = () => showRemote(null);602 $$(".remote").forEach((b) => { b.onclick = () => showRemote($("#" + b.dataset.note)); });603 $("#nccopy").onclick = async () => { await navigator.clipboard.writeText($("#nclink").href); toast("Link copied; paste it into your signer app"); };604 $("#bunkerform").onsubmit = guard(async (ev) => {605 $("#remotenote").textContent = "Connecting…";606 try { await connectBunker(ev.target.url.value); } catch (e) { $("#remotenote").textContent = e.message; throw e; }607 $("#remotenote").textContent = ""; ev.target.reset();608 await remoteDone();609 });610 $("#signout").onclick = async () => {611 if (remote) { const r = remote; remote = null; try { await withTimeout(r.logout(), 5000, "The signer"); } catch { /* the session is gone either way */ } }612 localStorage.removeItem("nip46");613 me = null; localStorage.removeItem("me"); renderHeader(); await loadAdmin(); await loadPeople();614 };615 const claimNow = async (note) => {616 if (!signer.ready()) { note.textContent = "Sign the claim with a nostr extension or a remote signer."; showRemote(note); return; }617 me = await signer.getPublicKey();618 const r = await rpc("claim");619 if (!r.claimed) throw new Error("Somebody else claimed it first.");620 localStorage.setItem("me", me);621 toast("It's yours.");622 await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople();623 if (r.converted && confirm("This relay began as a temporary one: anyone can write, and everything is deleted after 14 days. Switch to the default rules and keep everything from now on? Each rule can be changed later on the Rules and Storage tabs.")) {624 await rpc("resetrules"); toast("Rules reset"); await loadInfo(); await loadAdmin();625 }626 };627 $("#claim").onclick = guard(() => claimNow($("#claimnote")));628 $("#claimlease").onclick = guard(() => claimNow($("#leasenote")));629 // Jobs run in the background; the table follows them while the tab is open.630 let jobsTimer = 0;631 const fmtJob = (j) => {632 const what = j.kind === "mirror" ? "mirror site" : j.kind === "import" ? "import" : j.label === "backfill" ? "fetch my history" : j.kind === "pull" ? "pull" : "rebroadcast";633 const f = [];634 if (j.filter.authors) f.push(j.filter.authors.length === 1 && j.filter.authors[0] === me ? "my events" : j.filter.authors.length + " authors");635 if (j.filter.kinds) f.push("kinds " + j.filter.kinds.join(", "));636 if (j.filter.since) f.push("since " + fmtDay(j.filter.since));637 const when = j.every ? "every " + (j.every === 24 ? "day" : j.every + " h") + (j.nextRun ? ", next " + fmtTime(j.nextRun) : "") : "once";638 const l = j.last;639 const count = (stored, blobs, sent, refused) => (j.kind === "mirror" ? blobs.toLocaleString() + " files mirrored" : j.kind === "import" ? stored.toLocaleString() + " events" + ((j.last ? j.last.duplicates : j.duplicates) ? ", " + (j.last ? j.last.duplicates : j.duplicates) + " already here" : "") : j.kind === "pull" ? stored.toLocaleString() + " events" + (blobs ? ", " + blobs + " files" : "") : sent.toLocaleString() + " sent" + (refused ? ", " + refused + " refused" : ""));640 const res = j.running ? "running: " + count(j.stored, j.blobs, j.sent, j.refused) + "..." : !l ? "waiting" : l.error ? "failed: " + l.error : count(l.stored, l.blobs, l.sent, l.refused) + (l.skipped ? ", " + l.skipped + " skipped" : "") + ", " + fmtTime(l.finishedAt);641 const sources = j.running ? j.pullSources : l?.sources;642 const details = sources?.length ? '<details><summary>Source results</summary>' + sources.map((s) => '<p><code>' + esc(s.url) + '</code>: ' + esc(s.status) + ', ' + s.stored + ' stored, ' + s.skipped + ' skipped' + (s.error || s.warning ? '<br>' + esc(s.error || s.warning) : '') + '</p>').join('') + '</details>' : '';643 const targets = j.kind === "push" && j.targetStatus ? "<br><small>" + Object.entries(j.targetStatus).map(([u, s]) => esc(u) + ": " + esc(s.status)).join("<br>") + "</small>" : "";644 return "<tr><td>" + what + "</td><td class=\"mono\">" + j.relays.map(esc).join("<br>") + targets + "</td><td>" + (f.join(", ") || "everything") + "</td><td>" + when + "</td><td>" + esc(res) + details + "</td><td>" + (j.running ? "" : ib("undo", "Run now", "runjob", j.id)) + ib("x", "Remove", "removejob", j.id) + "</td></tr>";645 };646 async function pollJobs() {647 clearTimeout(jobsTimer);648 let jobs;649 try { jobs = await rpc("listjobs"); } catch { return; }650 $("#jobs tbody").innerHTML = jobs.length ? jobs.map(fmtJob).join("") : '<tr><td colspan="6" class="muted">no jobs yet</td></tr>';651 if (myRole === "owner") {652 try {653 const deliveries = await rpc("deliverystatus");654 $("#deliveries tbody").innerHTML = deliveries.length ? deliveries.map((d) => '<tr><td class="mono" title="' + esc(d.event_id) + '">' + esc(d.event_id.slice(0, 12)) + '</td><td class="mono">' + esc(d.target) + '</td><td>' + esc(d.status) + '</td><td>' + d.attempts + '</td><td>' + esc(d.error || "") + '</td></tr>').join("") : '<tr><td colspan="5" class="muted">no automatic deliveries yet</td></tr>';655 } catch { /* unavailable to non-owners */ }656 }657 if (jobs.some((j) => j.running || (j.nextRun && j.nextRun <= Math.floor(Date.now() / 1000) + 1))) jobsTimer = setTimeout(pollJobs, 3000);658 }659 const urls = (s) => s.split(/[\s,]+/).map((u) => u.trim()).filter(Boolean);660 const kindsOf = (s) => s.split(/[\s,]+/).map((k) => parseInt(k, 10)).filter((k) => Number.isInteger(k) && k >= 0);661 $("#pullform").onsubmit = guard(async (ev) => {662 const f = ev.target;663 await rpc("addjob", { kind: "pull", relays: [f.url.value.trim()], every: +f.every.value });664 toast(+f.every.value ? "Mirror scheduled" : "Pull started"); f.reset(); await pollJobs();665 });666 $("#backfillform").onsubmit = guard(async (ev) => {667 const f = ev.target;668 await rpc("backfill", urls(f.relays.value));669 toast("Fetching your history"); f.reset(); await pollJobs();670 });671 $("#pushform").onsubmit = guard(async (ev) => {672 const f = ev.target;673 const filter = {};674 const kinds = kindsOf(f.kinds.value); if (kinds.length) filter.kinds = kinds;675 const days = +f.days.value; if (days > 0) filter.since = Math.floor(Date.now() / 1000) - days * 86400;676 await rpc("addjob", { kind: "push", relays: urls(f.targets.value), filter, every: +f.every.value });677 toast("Rebroadcast started"); f.reset(); await pollJobs();678 });679 // ---- presets: writes, reads, kinds and keep-for rules in one click ----680 let presets = null;681 async function renderPresets() {682 if (!presets) { try { presets = await rpc("listpresets"); } catch { presets = []; } }683 $("#presets").innerHTML = presets.map((p) => '<button class="btn" data-preset="' + esc(p.name) + '" title="' + esc(p.about) + '">' + esc(p.title) + "</button>").join("");684 $("#presetsourcerow").classList.toggle("hidden", !presets.some((p) => p.source));685 }686 $("#presets").addEventListener("click", async (ev) => {687 const b = ev.target.closest("button[data-preset]"); if (!b) return;688 const p = (presets || []).find((x) => x.name === b.dataset.preset); if (!p) return;689 if (!confirm(p.title + ": " + p.about + "\n\nThis replaces the writes and reads rules, the directory setting, the kind rules and the keep-for rules" + (p.connections ? ", and the Connect fold's shortcuts" : "") + ". Limits, identity, people and bans stay.")) return;690 const source = $("#presetsource").value.trim();691 if (p.source === "required" && !source) { $("#presetnote").textContent = p.title + " needs a source relay to mirror; enter its wss:// URL first."; return; }692 b.disabled = true;693 try { policy = await rpc("applypreset", p.name, p.source && source ? { source } : undefined); $("#presetnote").textContent = "Now: " + p.about + (policy.job ? " Mirroring " + source + " every " + policy.job.every + " h." : "") + (p.connections ? " The Connect fold shows " + p.connections.map((c) => c.template).join(", ") + "." : ""); toast(p.title + " applied"); await loadInfo(); await loadAdmin(); }694 catch (e) { toast(e.message); } finally { b.disabled = false; }695 });696697 // ---- connect: the fold's shortcuts, picked from the library ----698 // The list is edited in place and saved whole, in the order shown; the699 // relay checks every entry against the library when it takes the list.700 // One row at a time opens for its title and inputs, and Save stays at the701 // top, lit while something is unsaved.702 const VIS = [["public", "anyone"], ["auth", "anyone signed in"], ["members", "members"], ["owner", "only me"]];703 let connLib = null, conns = null, connEditing = -1, connDirty = false;704 async function loadConnectTab() {705 try { [connLib, conns] = await Promise.all([rpc("listconnectiontemplates"), rpc("listconnections")]); } catch (e) { $("#connnote").textContent = e.message; return; }706 connEditing = -1; setConnDirty(false);707 renderConns(); renderConnLib();708 }709 function setConnDirty(v) { connDirty = v; $("#connsave").disabled = !v; $("#connnote").textContent = v ? "Unsaved changes" : ""; }710 const connTemplate = (name) => (connLib || []).find((t) => t.name === name) || { name, title: name, app: "", where: "", icon: "app", inputs: [], available: true, feature: "" };711 function renderConns() {712 const rows = conns.map((c, i) => {713 const t = connTemplate(c.template), editing = i === connEditing;714 const vis = VIS.map(([v, l]) => '<option value="' + v + '"' + (c.visibility === v ? " selected" : "") + ">" + l + "</option>").join("");715 const inputs = (t.inputs || []).map((inp) => "<label><span>" + esc(inp.label) + '</span><input class="txt" data-input="' + esc(inp.name) + '" value="' + esc((c.inputs || {})[inp.name] || "") + '" placeholder="' + esc(inp.placeholder || inp.default || "") + '"' + (inp.pattern ? ' pattern="' + esc(inp.pattern) + '"' : "") + ' maxlength="500"></label>').join("");716 const edit = editing ? '<div class="conn-edit"><label><span>Title</span><input class="txt" data-field="title" value="' + esc(c.title || "") + '" placeholder="' + esc(t.title) + '" maxlength="40"></label>' + inputs + "</div>" : "";717 return '<div class="conn' + (editing ? " editing" : "") + '" data-i="' + i + '"><i class="app-ic">' + (IC[t.icon] || IC.app) + '</i><div class="conn-main"><b>' + esc(c.title || t.title) + "</b><small>" + esc(t.app + (t.where ? ", " + t.where : "")) + "</small>" + (t.available ? "" : '<span class="pill bad">needs ' + esc(t.feature) + ", off</span>") + '</div><select class="txt conn-vis" data-field="visibility" title="Who sees it">' + vis + '</select><div class="conn-acts"><button class="ib" data-conn="edit" title="' + (editing ? "Done" : "Edit title and inputs") + '" aria-label="Edit">' + IC.pen + '</button><button class="ib" data-conn="up" title="Move up" aria-label="Move up">' + IC.up + '</button><button class="ib" data-conn="down" title="Move down" aria-label="Move down">' + IC.down + '</button><button class="ib danger" data-conn="rm" title="Remove" aria-label="Remove">' + IC.x + "</button></div>" + edit + "</div>";718 });719 $("#conns").innerHTML = rows.length ? rows.join("") : '<p class="empty">No shortcuts. Add some from the library below.</p>';720 }721 function renderConnLib() {722 $("#connlib").innerHTML = connLib.map((t) => '<div class="app"><div class="app-head"><i class="app-ic">' + (IC[t.icon] || IC.app) + "</i><b>" + esc(t.title) + "</b><small>" + esc(t.app + (t.where ? ", " + t.where : "")) + "</small>" + (t.visibility !== "public" ? '<span class="pill">' + VISIBILITY[t.visibility] + "</span>" : "") + "</div><p>" + esc(t.about) + '</p><div class="app-acts"><button class="btn pri" data-connadd="' + esc(t.name) + '">Add</button>' + (t.feature ? '<span class="pill' + (t.available ? " on" : " bad") + '">' + esc(t.feature) + (t.available ? " on" : " off") + "</span>" : "") + "</div></div>").join("");723 }724 // readConns takes the rows as edited back into the list before it is redrawn or saved.725 function readConns() {726 $$("#conns .conn").forEach((row) => {727 const c = conns[+row.dataset.i]; if (!c) return;728 c.visibility = row.querySelector('[data-field="visibility"]').value;729 const title = row.querySelector('[data-field="title"]');730 if (!title) return;731 if (title.value.trim()) c.title = title.value.trim(); else delete c.title;732 const inputs = {};733 row.querySelectorAll("[data-input]").forEach((inp) => { if (inp.value.trim()) inputs[inp.dataset.input] = inp.value.trim(); });734 if (Object.keys(inputs).length) c.inputs = inputs; else delete c.inputs;735 });736 }737 $("#conns").addEventListener("input", () => setConnDirty(true));738 $("#conns").addEventListener("click", (ev) => {739 const b = ev.target.closest("button[data-conn]"); if (!b) return;740 readConns();741 const i = +b.closest(".conn").dataset.i, act = b.dataset.conn;742 if (act === "edit") connEditing = connEditing === i ? -1 : i;743 else if (act === "rm") { conns.splice(i, 1); connEditing = -1; setConnDirty(true); }744 else {745 const j = act === "up" ? i - 1 : i + 1; if (j < 0 || j >= conns.length) return;746 [conns[i], conns[j]] = [conns[j], conns[i]];747 if (connEditing === i) connEditing = j; else if (connEditing === j) connEditing = i;748 setConnDirty(true);749 }750 renderConns();751 if (act === "edit" && connEditing === i) { const f = $("#conns .conn.editing input"); if (f) f.focus(); }752 });753 $("#connlib").addEventListener("click", (ev) => {754 const b = ev.target.closest("button[data-connadd]"); if (!b) return;755 const t = connTemplate(b.dataset.connadd);756 readConns();757 conns.push({ template: t.name, visibility: t.visibility || "public" });758 connEditing = t.inputs && t.inputs.length ? conns.length - 1 : connEditing;759 setConnDirty(true);760 renderConns();761 $("#conns").lastElementChild.scrollIntoView({ behavior: "smooth", block: "nearest" });762 });763 // Not through guard: the button's disabled state is the dirty flag's, so764 // guard's re-enable after the call would light it up again.765 $("#connsave").onclick = async () => {766 const b = $("#connsave"); b.disabled = true;767 try {768 readConns();769 conns = await rpc("setconnections", conns);770 toast(conns.length ? "Saved " + conns.length + (conns.length === 1 ? " shortcut" : " shortcuts") : "Saved: no shortcuts");771 setConnDirty(false); renderConns(); loadConnect();772 } catch (e) { toast(e.message); b.disabled = false; }773 };774775 // ---- wire me in: this relay in the owner's own lists ----776 // The lists are replaceable, so a fresh list with only this relay would777 // clobber the real one once it spread. Every publish starts from the778 // newest copy found here or on the indexers, verified when the signer779 // library is around, and adds or removes this relay in it.780 const INDEXERS = ["wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.damus.io", "wss://nos.lol"];781 const LISTS = [782 { kind: 10002, tag: "r", title: "Relay list", nip: "NIP-65", about: "where clients read your notes and send you mentions" },783 { kind: 10050, tag: "relay", title: "DM inbox", nip: "NIP-17", about: "where people send you private messages" },784 { kind: 10007, tag: "relay", title: "Search relays", nip: "NIP-51", about: "where clients run your searches" },785 { kind: 10063, tag: "server", title: "Blossom servers", nip: "BUD-03", about: "where clients upload and look for your files" },786 ];787 const mineFor = (l) => (l.tag === "server" ? location.origin : wsURL);788 const normURL = (u) => { try { const x = new URL(String(u).trim()); return (x.host + x.pathname).replace(/\/+$/, "").toLowerCase(); } catch { return String(u).trim().toLowerCase(); } };789 const isMine = (l, t) => t[0] === l.tag && normURL(t[1] || "") === normURL(mineFor(l));790 const listHas = (list, l) => !!list && list.tags.some((t) => isMine(l, t));791 // mergeList is pure: the newest list with this relay put first or taken out, every other tag kept.792 function mergeList(list, l, include) {793 const tags = (list ? list.tags : []).filter((t) => !isMine(l, t));794 if (include) tags.unshift([l.tag, mineFor(l)]);795 return { kind: l.kind, created_at: Math.floor(Date.now() / 1000), content: list ? list.content : "", tags };796 }797 const relaysIn = (list) => list.tags.filter((t) => (t[0] === "r" || t[0] === "relay") && /^wss?:\/\//i.test(t[1] || "")).map((t) => t[1].trim());798 // overWS opens one socket, sends one message, feeds answers to onMessage until it says done or time runs out.799 function overWS(url, ms, first, onMessage) {800 return new Promise((res) => {801 let ws = null, done = false, out = null;802 const finish = () => { if (done) return; done = true; clearTimeout(timer); try { if (ws) ws.close(); } catch { /* closed */ } res(out); };803 const timer = setTimeout(finish, ms);804 try { ws = new WebSocket(url); } catch { return finish(); }805 ws.onopen = () => ws.send(JSON.stringify(first));806 ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } if (!Array.isArray(d)) return; if (onMessage(d, (v) => { out = v; })) finish(); };807 ws.onerror = finish; ws.onclose = finish;808 });809 }810 function fetchNewest(url, filter, ms) {811 let best = null;812 return overWS(url, ms, ["REQ", "w", filter], (d, set) => {813 if (d[0] === "EVENT" && d[1] === "w" && d[2] && (!best || d[2].created_at > best.created_at)) { best = d[2]; set(best); }814 return (d[0] === "EOSE" || d[0] === "CLOSED") && d[1] === "w";815 });816 }817 function publishTo(url, event, ms) {818 return overWS(url, ms, ["EVENT", event], (d, set) => { if (d[0] === "OK" && d[1] === event.id) { set({ url, ok: !!d[2], msg: d[3] || "" }); return true; } return false; })819 .then((r) => r || { url, ok: false, msg: "no answer" });820 }821 // bridge signs a NIP-98 request to this relay's HTTP door, so the owner's own reads and writes pass any read rule.822 async function bridge(path, body) {823 const url = location.origin + path, raw = JSON.stringify(body);824 const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", url], ["method", "POST"], ["payload", await sha256hex(raw)]] });825 const resp = await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: "Nostr " + btoa(JSON.stringify(ev)) }, body: raw });826 const json = await resp.json();827 if (!resp.ok) throw new Error(json.error || "HTTP " + resp.status);828 return json;829 }830 const wire = {};831 function renderWire() {832 const pill = (state) => {833 const cls = state === "listed" ? "on" : state === "failed" ? "bad" : state === "not listed" ? "off" : "";834 const label = state === "checking" || state === "publishing" ? state + "\u2026" : state;835 return '<span class="pill ' + cls + '">' + esc(label) + "</span>";836 };837 $("#wire").innerHTML = LISTS.map((l) => {838 const w = wire[l.kind] || { state: "", results: [], list: null };839 const busy = w.state === "checking" || w.state === "publishing";840 const btn = (act, cls, label) => '<button class="btn ' + cls + '" data-wire="' + l.kind + '" data-do="' + act + '"' + (busy ? " disabled" : "") + ">" + label + "</button>";841 const buttons = w.state === "listed" ? btn("remove", "", "Remove this relay") : btn("add", "pri", "Add this relay") + btn("check", "", w.state ? "Check again" : "Check");842 const n = w.list ? w.list.tags.filter((t) => t[0] === l.tag).length : 0;843 const from = w.list ? "<span>" + (n === 1 ? "1 entry" : n + " entries") + ", " + fmtTime(w.list.created_at) + "</span>" : w.state && !busy ? "<span>no list found</span>" : "";844 const sent = (w.results || []).map((r) => '<span class="chip' + (r.ok ? "" : " bad") + '" title="' + esc(r.msg) + '">' + esc(r.url.replace(/^wss?:\/\//, "")) + (r.ok ? "" : " failed") + "</span>").join("");845 const meta = (w.state ? pill(w.state) : "") + from + sent;846 return '<div class="wire-row"><div class="wire-main"><b>' + l.title + '</b> <small class="muted">' + l.nip + "</small><br><small>" + l.about + '</small></div><div class="wire-side"><div class="wire-acts">' + buttons + '</div><div class="wire-meta">' + meta + "</div></div></div>";847 }).join("");848 }849 async function wireCheck(l) {850 const w = (wire[l.kind] = { state: "checking", list: null, results: [] }); renderWire();851 const filter = { kinds: [l.kind], authors: [me], limit: 1 };852 const found = [];853 try { found.push(...(await bridge("/query", [filter]))); } catch { /* the relay may hold none */ }854 const remote = await Promise.all(INDEXERS.map((u) => fetchNewest(u, filter, 4000)));855 let lib = null; try { lib = await signerLib(); } catch { /* unverified lists are still the owner's own, by pubkey */ }856 for (const e of remote) if (e && e.pubkey === me && e.kind === l.kind && Array.isArray(e.tags) && (!lib || lib.verifyEvent(e))) found.push(e);857 w.list = found.sort((a, b) => b.created_at - a.created_at)[0] || null;858 w.state = listHas(w.list, l) ? "listed" : "not listed"; renderWire();859 }860 async function wirePublish(l, include) {861 if (!wire[l.kind] || !wire[l.kind].state || wire[l.kind].state === "failed") await wireCheck(l);862 const w = wire[l.kind]; w.state = "publishing"; w.results = []; renderWire();863 try {864 const signed = await signer.signEvent(mergeList(w.list, l, include));865 const here = await bridge("/events", signed).then((r) => ({ url: wsURL, ok: !!r.accepted, msg: r.message || "" })).catch((e) => ({ url: wsURL, ok: false, msg: e.message }));866 const targets = [...new Set([...relaysIn(signed), ...INDEXERS])].filter((u) => normURL(u) !== normURL(wsURL));867 const rest = await Promise.all(targets.map((u) => publishTo(u, signed, 6000)));868 w.results = [here, ...rest]; w.list = signed; w.state = include ? "listed" : "not listed";869 toast((include ? "Added to your " : "Removed from your ") + l.title.toLowerCase());870 } catch (e) { w.state = "failed"; w.results = [{ url: wsURL, ok: false, msg: e.message }]; toast(e.message); }871 renderWire();872 }873 $("#wire").addEventListener("click", (ev) => {874 const b = ev.target.closest("button[data-wire]"); if (!b) return;875 const l = LISTS.find((x) => String(x.kind) === b.dataset.wire); if (!l) return;876 if (b.dataset.do === "check") wireCheck(l); else wirePublish(l, b.dataset.do === "add");877 });878879 // ---- custom domains: this relay under a hostname the owner controls ----880 let domainSites = [];881 const domainOptions = (current = "") => [{ label: "", title: "This relay" }, ...domainSites.map((s) => ({ label: s.label, title: (s.d || (s.kind === 5128 ? "snapshot" : "root")) + " / " + key(s.author) })), ...(current && !domainSites.some((s) => s.label === current) ? [{ label: current, title: "Site " + current }] : [])].map((s) => '<option value="' + esc(s.label) + '"' + (s.label === current ? " selected" : "") + ">" + esc(s.title) + "</option>").join("");882 let domains = null; // null: not enabled on this host883 function renderDomains() {884 $("#domains").innerHTML = (domains || []).map((d) => {885 const state = d.ready ? "active" : "hostname " + d.status.replace(/_/g, " ") + ", certificate " + d.sslStatus.replace(/_/g, " ");886 const btn = (act, cls, label) => '<button class="btn ' + cls + '" data-domain="' + esc(d.host) + '" data-do="' + act + '">' + label + "</button>";887 const rows = d.ready ? "" : '<div class="scroll"><table class="events"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th></th></tr></thead><tbody>' + d.records.map((r) => "<tr><td>" + esc(r.type) + '</td><td class="mono">' + esc(r.name) + '</td><td class="mono">' + esc(r.value) + "</td><td>" + esc(r.note) + "</td></tr>").join("") + "</tbody></table></div>";888 return '<div class="row"><b class="mono">' + esc(d.host) + '</b><span class="note">' + esc(state) + "</span>" + btn("check", "", "Check") + btn("remove", "danger", "Remove") + '</div><label class="row"><span>Destination</span><select class="txt" data-domain-site="' + esc(d.host) + '">' + domainOptions(d.site || "") + "</select></label>" + rows;889 }).join("");890 }891 async function loadDomains() {892 try { [domains, domainSites] = await Promise.all([rpc("listdomains"), rpc("listsites")]); $("#adddomain select[name=site]").innerHTML = domainOptions(); $("#domainnote").textContent = domains.length ? "" : "No custom domain yet."; $("#adddomain").classList.remove("hidden"); }893 catch (e) { domains = null; $("#domainnote").textContent = /^unsupported/.test(e.message) ? "Custom domains are not enabled on this host." : e.message; $("#adddomain").classList.add("hidden"); }894 renderDomains();895 }896 $("#adddomain").onsubmit = guard(async (ev) => {897 const f = ev.target;898 await rpc("adddomain", f.host.value.trim(), f.site.value); f.reset(); toast("Domain added; now create the CNAME"); await loadDomains();899 });900 $("#domains").addEventListener("change", guard(async (ev) => {901 const select = ev.target.closest("select[data-domain-site]"); if (!select) return;902 await rpc("setdomainsite", select.dataset.domainSite, select.value);903 toast("Domain destination saved"); await loadDomains();904 }));905 $("#domains").addEventListener("click", async (ev) => {906 const b = ev.target.closest("button[data-domain]"); if (!b) return;907 const host = b.dataset.domain;908 if (b.dataset.do === "remove" && !confirm("Remove " + host + "? Its certificate goes with it and the name stops answering.")) return;909 b.disabled = true;910 try {911 if (b.dataset.do === "remove") { await rpc("removedomain", host); toast("Removed"); }912 else { const d = await rpc("checkdomain", host); toast(d.ready ? host + " is live" : "Not yet: " + (d.status === "active" ? "certificate pending" : "waiting for the CNAME")); }913 await loadDomains();914 } catch (e) { toast(e.message); } finally { b.disabled = false; }915 });916917 $("#access").onsubmit = guard(async (ev) => {918 const f = ev.target;919 const openKinds = f.openKinds.value.split(/[\s,]+/).filter(Boolean).map(Number);920 if (openKinds.some((k) => !Number.isInteger(k) || k < 0 || k > 65535)) throw new Error("Open kinds must be whole numbers.");921 policy = await rpc("setpolicy", { writes: f.writes.value, reads: f.reads.value, openKinds, guestReplies: f.guestReplies.checked, minPow: +f.minPow.value, maxFuture: +f.maxFuture.value, maxLimit: +f.maxLimit.value, maxSubs: +f.maxSubs.value, maxMessageKB: +f.maxMessageKB.value, eventsPerMinute: +f.eventsPerMinute.value, reqsPerMinute: +f.reqsPerMinute.value, maxBlobMB: +f.maxBlobMB.value });922 toast("Rules saved"); await loadInfo();923 });924 $("#git-capacity").onsubmit = guard(async (ev) => {925 const f = ev.target, bytes = (name, divisor) => Math.round(Number(f[name].value) * divisor);926 const git = {927 maxRepositories: Math.floor(Number(f.maxRepositories.value)), maxRelayBytes: bytes("maxRelayGiB", GiB), maxCompressedBytes: bytes("maxCompressedGiB", GiB),928 maxPackBytes: bytes("maxPackMiB", MiB), maxObjectBytes: bytes("maxObjectMiB", MiB), maxObjects: Math.floor(Number(f.maxObjects.value)), maxRawBytes: bytes("maxRawGiB", GiB),929 maxTransactionRawBytes: bytes("maxTransactionRawMiB", MiB), maxTransactionObjects: Math.floor(Number(f.maxTransactionObjects.value)), maxMetadataBytes: bytes("maxMetadataMiB", MiB),930 maxRefs: Math.floor(Number(f.maxRefs.value)), maxGraphEdges: Math.floor(Number(f.maxGraphEdges.value)), maxFetchBytes: bytes("maxFetchGiB", GiB),931 };932 policy = await rpc("setpolicy", { git });933 toast("Git capacity saved"); await loadInfo();934 });935 $("#notify").onsubmit = guard(async (ev) => {936 const f = ev.target;937 policy = await rpc("setpolicy", { notify: { reports: f.reports.checked, fuel: f.fuel.checked, jobs: f.jobs.checked, succession: f.succession.checked, digest: f.digest.checked } });938 toast("Notifications saved");939 });940 $("#notifytest").onclick = guard(async () => {941 const r = await rpc("notifytest");942 toast(r.sent ? "Sent. Look for a message from the relay in your DMs." : "Could not send.");943 });944 $("#identity").onsubmit = guard(async (ev) => {945 const f = ev.target;946 const list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);947 policy = await rpc("setpolicy", { name: f.name.value, contact: f.contact.value, description: f.description.value, icon: f.icon.value,948 banner: f.banner.value, postingPolicy: f.postingPolicy.value, privacyPolicy: f.privacyPolicy.value, tags: list(f.tags.value), languageTags: list(f.languageTags.value), relayCountries: list(f.relayCountries.value) });949 toast("Identity saved"); await loadInfo(); await loadPeople(); loadCard();950 });951 $("#addmember").onsubmit = guard(async (ev) => {952 const f = ev.target; const pk = npubToHex(f.pubkey.value); if (!pk) throw new Error("That is not a pubkey.");953 await rpc("setmember", pk, { name: f.name.value.trim() || null, note: f.note.value }); f.reset(); toast("Member added"); await loadLists();954 });955 $("#addblock").onsubmit = guard(async (ev) => {956 const f = ev.target;957 await rpc("blockip", f.ip.value.trim(), f.reason.value); f.reset(); toast("Blocked"); await loadLists();958 });959 $("#wordsform").onsubmit = guard(async (ev) => {960 const words = ev.target.words.value.split("\n").map((s) => s.trim()).filter(Boolean);961 const kept = await rpc("setblockedwords", words);962 policy = await rpc("setpolicy", { blockedWordsInTags: ev.target.inTags.checked });963 ev.target.words.value = kept.join("\n"); toast(kept.length ? kept.length + " words blocked" : "No words blocked");964 });965 $("#thresholdform").onsubmit = guard(async (ev) => {966 policy = await rpc("setpolicy", { reportThreshold: +ev.target.reportThreshold.value });967 toast(policy.reportThreshold ? "Hidden after " + policy.reportThreshold + " reports" : "Never hidden by reports"); await loadLists();968 });969 $("#addban").onsubmit = guard(async (ev) => {970 const pk = npubToHex(ev.target.pubkey.value); if (!pk) throw new Error("That is not a pubkey.");971 await rpc("banpubkey", pk, ev.target.reason.value, ev.target.erase.checked); ev.target.reset(); toast(ev.target.erase.checked ? "Banned and erased" : "Banned"); await loadLists();972 });973 $("#mintinvite").onsubmit = guard(async (ev) => {974 const f = ev.target;975 const inv = await rpc("createinvite", +f.ttl.value, +f.max.value, f.note.value);976 await navigator.clipboard.writeText(location.origin + "/invite/" + inv.code).catch(() => {});977 f.note.value = ""; toast("Invite created and copied"); await loadLists();978 });979 $("#kindform").onsubmit = guard(async (ev) => {980 const rule = ev.submitter.value; const k = +ev.target.kind.value;981 await rpc(rule === "allow" ? "allowkind" : "disallowkind", k); ev.target.reset(); toast((rule === "allow" ? "Allowed kind " : "Blocked kind ") + k); await loadLists();982 });983 $("#more").onclick = guard(() => loadEvents(false));984 $("#dumpform").onsubmit = guard(async (ev) => {985 const f = ev.target;986 policy = await rpc("setpolicy", { dumps: f.dumps.value, dumpsKeep: Math.max(1, Math.min(60, Math.floor(+f.keep.value || 7))) });987 toast(policy.dumps === "off" ? "Dumps off" : "Dumping " + policy.dumps); await loadDumps();988 });989 $("#importform").onsubmit = guard(async (ev) => {990 const file = ev.target.file.files[0]; if (!file) return;991 if (!signer.ready()) throw new Error(NO_SIGNER);992 if (file.size > 64 * 1024 * 1024) throw new Error("At most 64 MB per import.");993 const body = await file.text();994 const url = location.origin + "/import?name=" + encodeURIComponent(file.name);995 const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", url], ["method", "PUT"], ["payload", await sha256hex(body)]] });996 const resp = await fetch(url, { method: "PUT", headers: { authorization: "Nostr " + btoa(JSON.stringify(token)), "content-type": "application/x-ndjson" }, body });997 const r = await resp.json();998 if (!resp.ok) throw new Error(r.error || "import failed");999 ev.target.reset(); toast("Importing " + fmtBytes(r.bytes)); await pollJobs();1000 });1001 $("#dumpnow").onclick = guard(async () => { const d = await rpc("dumpnow"); toast("Dumped " + d.events.toLocaleString() + " events"); await loadStorage(); });1002 async function backupRequest(path, method, body) {1003 if (!signer.ready()) throw new Error(NO_SIGNER);1004 const hash = await sha256hex(body || "");1005 const token = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", location.origin + path], ["method", method], ["payload", hash]] });1006 return fetch(path, { method, headers: { authorization: "Nostr " + btoa(JSON.stringify(token)), ...(body ? { "content-type": "application/json" } : {}) }, body });1007 }1008 $("#backupnow").onclick = guard(async () => { const id = $("#backupid").value.trim() || "backup"; const r = await rpc("backupnow", id); const resp = await backupRequest("/backups/" + id, "GET"); if (!resp.ok) throw new Error("backup download failed"); const a = document.createElement("a"); a.href = URL.createObjectURL(await resp.blob()); a.download = host.split(".")[0] + "-" + id + ".json"; a.click(); URL.revokeObjectURL(a.href); toast("Backup downloaded"); await loadStorage(); void r; });1009 async function selectedBackup() { const f = $("#backupfile").files[0]; if (!f) throw new Error("Choose a backup archive first."); if (f.size > 8 * 1024 * 1024) throw new Error("Backups are limited to 8 MB."); return { file: f, body: await f.text() }; }1010 let backupPreview = null;1011 $("#backupfile").onchange = () => { backupPreview = null; $("#backup-preview").textContent = ""; $("#backuprestore").disabled = true; };1012 $("#backupremote").onclick = () => showRemote(null);1013 $("#backuppreview").onclick = guard(async () => { const selected = await selectedBackup(); const r = await (await backupRequest("/backups/preview", "POST", selected.body)).json(); if (r.error) throw new Error(r.error); backupPreview = { file: selected.file, body: selected.body }; $("#backuprestore").disabled = false; const s = r.result.source; $("#backup-preview").innerHTML = '<p><b>Ready to restore.</b> ' + esc(String(r.result.events)) + ' events, ' + esc(String(r.result.blobs)) + ' files, ' + esc(String(r.result.git)) + ' Git objects, ' + esc(fmtBytes(r.result.bytes)) + '.</p><p>Source relay identity: <code>' + esc(s.relayIdentity || "unknown") + '</code></p><pre>' + esc(JSON.stringify(r.result.config, null, 2)) + '</pre>'; });1014 $("#backuprestore").onclick = guard(async () => { const selected = await selectedBackup(); if (!backupPreview || backupPreview.file !== selected.file || backupPreview.body !== selected.body) throw new Error("Preview this exact archive before restoring."); if (!confirm("Restore this archive onto this fresh relay? This cannot be undone.")) return; const r = await (await backupRequest("/backups/restore", "POST", selected.body)).json(); if (r.error) throw new Error(r.error); me = await signer.getPublicKey(); localStorage.setItem("me", me); toast("Backup restored"); await loadInfo(); await loadFuel(); await loadAdmin(); await loadPeople(); });1015 $("#treeform").onsubmit = guard(async (ev) => {1016 const f = ev.target;1017 policy = await rpc("setpolicy", { memberInvites: { depth: Math.max(0, Math.floor(+f.depth.value || 0)), quota: Math.max(0, Math.floor(+f.quota.value || 0)) } });1018 toast(policy.memberInvites.depth ? "Members may invite" : "Only you and moderators invite");1019 });1020 $("#mintmine").onclick = guard(async () => {1021 const inv = await rpc("createinvite", 259200, 1, "");1022 await navigator.clipboard.writeText(location.origin + "/invite/" + inv.code).catch(() => {});1023 toast("Invite created and copied"); await loadMine();1024 });1025 $("#exportcfg").onclick = guard(async () => {1026 const cfg = await rpc("exportconfig");1027 const a = document.createElement("a");1028 a.href = URL.createObjectURL(new Blob([JSON.stringify(cfg, null, 2)], { type: "application/json" }));1029 a.download = host.split(".")[0] + ".bind.ws.config.json";1030 a.click(); URL.revokeObjectURL(a.href);1031 });1032 $("#importcfg").onclick = () => $("#cfgfile").click();1033 $("#cfgfile").onchange = guard(async (ev) => {1034 const file = ev.target.files[0]; if (!file) return;1035 const cfg = JSON.parse(await file.text());1036 const check = await rpc("importconfig", cfg, { dryRun: true });1037 const lines = check.changes.summary.length ? check.changes.summary.join("\n") : "Nothing would change.";1038 const dropped = check.warnings.length ? "\n\nNot taken:\n" + check.warnings.join("\n") : "";1039 if (!confirm("Apply " + file.name + " to this relay?\n\n" + lines + dropped)) { ev.target.value = ""; return; }1040 await rpc("importconfig", cfg); ev.target.value = ""; toast("Configuration imported"); await loadInfo(); await loadAdmin(); await loadPeople();1041 });1042 // Succession: the status line under the heir form, from successionstatus.1043 async function renderSuccession() {1044 const el = $("#successionnote"), f = $("#succession");1045 let st;1046 try { st = await rpc("successionstatus"); } catch { el.textContent = ""; return; }1047 const sc = st.succession;1048 $("#clearsuccession").classList.toggle("hidden", !sc);1049 f.querySelector("button.btn:not(#clearsuccession)").textContent = sc ? "Change heir" : "Set heir";1050 if (!sc) { el.textContent = "No heir named. Last signed in " + fmtTime(st.ownerSeenAt) + "."; return; }1051 if (f.heir.querySelector('[value="' + sc.heir + '"]')) f.heir.value = sc.heir;1052 f.afterDays.value = String(sc.afterDays);1053 const who = (window.__members || []).find((m) => m.pubkey === sc.heir);1054 const heir = who && who.name ? who.name + "@" + host : short(sc.heir);1055 el.textContent = "Heir: " + heir + ". Last signed in " + fmtTime(st.ownerSeenAt) + (st.silentDays ? " (" + st.silentDays + " days ago)" : "") + ". " +1056 (st.warning ? "The warning month is running: the relay goes to " + heir + " on " + fmtDay(st.handoverAt) + " unless you sign in." : "If you stay away, it goes to " + heir + " around " + fmtDay(st.handoverAt) + ".") +1057 (st.log && st.log.length ? " Handed over before: " + st.log.map((l) => fmtDay(l.at) + " to " + short(l.to)).join(", ") + "." : "");1058 }1059 $("#succession").onsubmit = guard(async (ev) => {1060 const f = ev.target;1061 await rpc("setsuccession", { heir: f.heir.value, afterDays: +f.afterDays.value });1062 toast("Heir set"); await loadAdmin();1063 });1064 $("#clearsuccession").onclick = guard(async () => {1065 await rpc("clearsuccession");1066 toast("Heir cleared"); await loadAdmin();1067 });1068 $("#transfer").onsubmit = guard(async (ev) => {1069 const pk = ev.target.pubkey.value; if (!pk) return;1070 const name = host.split(".")[0];1071 const typed = prompt("This hands " + host + " to " + short(pk) + " for good. You stay on as a moderator. Type the relay name (" + name + ") to confirm.");1072 if (typed === null) return;1073 if (typed.trim() !== name) { toast("That didn't match; nothing changed."); return; }1074 await rpc("transferowner", pk);1075 toast("Transferred");1076 await loadInfo(); await loadAdmin(); await loadPeople();1077 });1078 // ---- fork: a new name with this relay's events, claim reserved for a key ----1079 $("#forkform").scope.onchange = (ev) => $("#forkkinds").classList.toggle("hidden", ev.target.value !== "kinds");1080 $("#joinform").onsubmit = guard(async (ev) => {1081 const f = ev.target;1082 policy = await rpc("setpolicy", { joinTerms: f.joinTerms.value, directoryPublic: f.directoryPublic.checked });1083 toast("Saved"); await loadInfo(); await loadPeople();1084 });1085 $("#forkform").onsubmit = guard(async (ev) => {1086 const f = ev.target;1087 const opts = { people: f.people.checked };1088 if (f.name.value.trim()) opts.name = f.name.value.trim().toLowerCase();1089 if (f.holder.value.trim()) { const pk = npubToHex(f.holder.value); if (!pk) throw new Error("That is not a pubkey."); opts.holder = pk; }1090 if (f.scope.value === "mine") opts.filter = { authors: [me] };1091 if (f.scope.value === "kinds") { const kinds = f.kinds.value.split(/[\s,]+/).filter(Boolean).map(Number); if (!kinds.length || kinds.some((k) => !Number.isInteger(k) || k < 0)) throw new Error("Give kinds as numbers."); opts.filter = { kinds }; }1092 if (!confirm("Fork this relay into a new name" + (opts.name ? " (" + opts.name + ")" : "") + "? It pulls " + (f.scope.value === "all" ? "everything" : f.scope.value === "mine" ? "your events" : "the chosen kinds") + (opts.people ? " and the people" : "") + ", and only " + (opts.holder ? "that key" : "you") + " can claim it.")) return;1093 const r = await rpc("forkrelay", opts);1094 $("#forknote").textContent = r.handover + " Expires " + fmtTime(r.expires_at) + ".";1095 $("#forkurl").textContent = r.console;1096 $("#forkresult").classList.remove("hidden");1097 toast("Forked to " + r.name);1098 });1099 $("#forkcopy").onclick = async () => { await navigator.clipboard.writeText($("#forkurl").textContent); toast("copied"); };1100 $("#deleterelay").onclick = guard(async () => {1101 const name = host.split(".")[0];1102 const typed = prompt("This deletes everything on " + host + " and gives the name up. Type the relay name (" + name + ") to confirm.");1103 if (typed === null) return;1104 if (typed.trim() !== name) { toast("That didn't match; nothing was deleted."); return; }1105 await rpc("deleterelay", name);1106 localStorage.removeItem("me");1107 location.href = "/";1108 });1109 $("#topup").onsubmit = guard((ev) => topUp(+ev.target.sats.value));1110 $("#inv-copy").onclick = async () => { await navigator.clipboard.writeText($("#inv-text").value); toast("invoice copied"); };1111 $$("button[data-copy]").forEach((b) => { b.onclick = async () => { await navigator.clipboard.writeText(b.dataset.copy === "ws" ? wsURL : location.origin); toast("copied"); }; });1112 document.addEventListener("click", async (ev) => {1113 const b = ev.target.closest("button[data-act]"); if (!b) return;1114 const act = b.dataset.act, id = b.dataset.id;1115 if (act === "copy") { await navigator.clipboard.writeText(id); toast("copied"); return; }1116 if ((act === "banpubkey" || act === "banevent" || act === "resolve:ban") && !confirm(act === "banevent" ? "Delete this event and refuse it forever?" : "Ban this author and refuse everything they post?")) return;1117 const erase = (act === "banpubkey" || act === "resolve:ban") && confirm("Also erase everything they wrote and uploaded here?");1118 if (act === "deleteblob" && !confirm("Delete this file for good?")) return;1119 if (act === "deletedump" && !confirm("Delete this dump?")) return;1120 if (act === "downloaddump") { b.disabled = true; try { await downloadDump(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }1121 if (act === "deletebackup" && !confirm("Delete this backup?")) return;1122 if (act === "downloadbackup") { b.disabled = true; try { await downloadBackup(id); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }1123 if (act === "deletebackup") { b.disabled = true; try { await rpc("deletebackup", id); toast("Backup deleted"); await loadBackups(); } catch (e) { toast(e.message); } finally { b.disabled = false; } return; }1124 if (act === "restorelist") {1125 if (!signer.ready()) { toast(NO_SIGNER); return; }1126 try {1127 const preview = await rpc("restorelist", id);1128 const d = preview.diff || {};1129 const added = (d.addedTags || []).map((t) => "+ " + JSON.stringify(t)).join("\n");1130 const removed = (d.removedTags || []).map((t) => "- " + JSON.stringify(t)).join("\n");1131 const changes = [added, removed, d.contentChanged ? "content changed" : "content unchanged"].filter(Boolean).join("\n");1132 if (!confirm("Restore this list version?\n\n" + (changes || "No tag or content changes") + "\n\nIt will be signed and published as the newest version.")) return;1133 const signed = await signer.signEvent(preview.draft);1134 const result = await bridge("/events", signed);1135 if (!result.accepted) throw new Error(result.message || "The relay refused the restored list.");1136 toast("List restored"); await loadListHistory(); await loadStorage();1137 } catch (e) { toast(e.message); } finally { b.disabled = false; }1138 return;1139 }1140 if ((act === "removemember" || act === "banpubkey") && (window.__members || []).some((m) => m.invited_by === id) && confirm("Also remove everyone this member invited, and everyone they invited in turn?")) {1141 b.disabled = true;1142 try { const r = await rpc("removesubtree", id); if (act === "banpubkey") await rpc("banpubkey", id, "", erase); toast("Removed " + r.removed.length); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }1143 return;1144 }1145 if (act === "removejob" && !confirm("Remove this job?")) return;1146 if (act === "purgekind") {1147 const kind = id === "" ? null : +id;1148 const label = kind === null ? "everything without its own rule" : kindName(kind) + " (kind " + kind + ")";1149 const typed = prompt("Purge " + label + " older than how many days? 0 purges all of them. This cannot be undone.", "30");1150 if (typed === null) return;1151 const days = Math.max(0, Math.floor(+typed || 0));1152 b.disabled = true;1153 try { const r = await rpc("purgekind", kind, days); toast("Purged " + r.deleted.toLocaleString() + " events"); await refresh(); } catch (e) { toast(e.message); } finally { b.disabled = false; }1154 return;1155 }1156 if (act === "saveretention") {1157 const kind = id === "" ? null : +id;1158 const days = Math.max(0, Math.floor(+b.closest("tr").querySelector(".days").value || 0));1159 b.disabled = true;1160 try { await rpc("setretention", kind, days); toast(days ? "Kept for " + days + " days" : "Kept forever"); await loadStorage(); await loadInfo(); } catch (e) { toast(e.message); } finally { b.disabled = false; }1161 return;1162 }1163 b.disabled = true;1164 try {1165 if (act === "savemember") { const tr = b.closest("tr"), role = tr.querySelector("select.role"), keep = tr.querySelector(".keep"), cap = tr.querySelector(".cap"); await rpc("setmember", id, { name: tr.querySelector(".name").value.trim() || null, note: tr.querySelector(".note").value, ...(role ? { role: role.value } : {}), ...(keep ? { keepDays: Math.max(0, Math.floor(+keep.value || 0)), maxBytes: Math.max(0, Math.floor(+cap.value || 0)) * 1024 } : {}) }); }1166 else if (act.startsWith("resolve:")) await rpc("resolvereport", id, act.slice(8), erase);1167 else if (act === "banpubkey") await rpc("banpubkey", id, "", erase);1168 else await rpc(act, act === "unrulekind" ? +id : id, "");1169 toast("Done"); await (myRole ? refresh() : loadMine());1170 if (act === "pinevent" || act === "unpinevent") await loadPins();1171 } catch (e) { toast(e.message); } finally { b.disabled = false; }1172 });11731174 // The share block: the card picture, the group naddr and its QR.1175 let card = null;1176 async function loadCard() {1177 try {1178 card = await (await fetch("/card.json", { cache: "no-store" })).json();1179 $("#cardimg").src = "/card.svg?t=" + Date.now();1180 $("#naddr").textContent = card.naddr || "";1181 const q = $("#naddrqr");1182 if (card.naddr) { q.src = "/qr.svg?text=" + encodeURIComponent(card.naddr); q.classList.remove("hidden"); } else q.classList.add("hidden");1183 } catch { /* the card is decoration */ }1184 }1185 // The Connect fold's shortcuts: the owner's list from /connect.json,1186 // resolved by the relay for whoever is looking. A signed-in viewer asks1187 // with a NIP-98 signature, so the relay can show what their visibility1188 // admits and fill the links made for them; anyone else sees the public1189 // ones. A shortcut's QR is drawn only when asked for.1190 const VISIBILITY = { public: "", auth: "signed in", members: "members", owner: "only you" };1191 async function signedGet(path) {1192 const url = location.origin + path;1193 const headers = {};1194 if (me && signer.ready()) {1195 try {1196 const ev = await signer.signEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["u", url], ["method", "GET"]] });1197 headers.authorization = "Nostr " + btoa(JSON.stringify(ev));1198 } catch { /* unsigned, then: the public shortcuts */ }1199 }1200 return fetch(url, { headers, cache: "no-store" });1201 }1202 let connectSeq = 0;1203 async function loadConnect() {1204 const seq = ++connectSeq;1205 let data;1206 try { const r = await signedGet("/connect.json"); data = await r.json(); if (data.error) throw new Error(data.error); } catch { data = { connections: [] }; }1207 if (seq !== connectSeq) return; // a later sign-in or sign-out answered first1208 renderConnect(data.connections || []);1209 }1210 function renderConnect(list) {1211 const enc = encodeURIComponent;1212 const open = (l, pri) => '<a class="btn' + (pri ? " pri" : "") + '" href="' + esc(l.href) + '"' + (/^nostr:/i.test(l.href) ? "" : ' target="_blank" rel="noopener"') + ">" + esc(l.label) + "</a>";1213 const copy = (l) => '<button class="btn" data-copytext="' + esc(l.copy) + '">' + IC.copy + esc(l.label) + "</button>";1214 // One Open leads; the first copy text rides beside it; every other link waits behind More.1215 const tile = (c) => {1216 const hrefs = c.links.filter((l) => l.href), copies = c.links.filter((l) => l.copy);1217 const acts = hrefs.length ? [open(hrefs[0], true), copies.length ? copy(copies[0]) : ""] : [copies.length ? copy(copies[0]) : ""];1218 const rest = [...hrefs.slice(1).map((l) => open(l, false)), ...copies.slice(1).map(copy)];1219 if (c.qr) acts.push('<button class="ib" data-qr="' + enc(c.qr) + '" title="QR code for a phone" aria-label="QR code for a phone">' + IC.qr + "</button>");1220 return '<div class="app"><div class="app-head"><i class="app-ic">' + (IC[c.icon] || IC.app) + "</i><b>" + esc(c.title) + "</b><small>" + esc(c.app + (c.where ? ", " + c.where : "")) + "</small>" + (VISIBILITY[c.visibility] ? '<span class="pill">' + VISIBILITY[c.visibility] + "</span>" : "") + "</div><p>" + esc(c.about) + '</p><div class="app-acts">' + acts.join("") + "</div>" + (rest.length ? '<details class="more"><summary>More</summary><div class="app-more">' + rest.join("") + "</div></details>" : "") + (c.needsUser ? '<p class="who-note">Sign in and the links made for you appear.</p>' : "") + '<div class="app-qr hidden"></div></div>';1221 };1222 $("#apps").innerHTML = list.map(tile).join("");1223 $("#apps-note").textContent = list.length ? "" : me && owner && me === owner ? "No shortcuts yet. Pick some on the Connect tab." : "The owner has not picked any app shortcuts yet.";1224 }1225 $("#apps").addEventListener("click", (ev) => {1226 const b = ev.target.closest("button[data-qr]"); if (!b) return;1227 const box = b.closest(".app").querySelector(".app-qr");1228 if (!box.firstChild) box.innerHTML = '<img src="/qr.svg?text=' + b.dataset.qr + '" alt="QR code" width="150" height="150">';1229 box.classList.toggle("hidden");1230 });1231 // The folds are a group: opening one closes the rest. Browsers with the1232 // details name attribute do this themselves; this covers the others.1233 $$(".folds > details").forEach((d) => d.addEventListener("toggle", () => { if (d.open) $$(".folds > details").forEach((o) => { if (o !== d && o.open) o.open = false; }); }));1234 document.addEventListener("click", async (ev) => {1235 const b = ev.target.closest("button[data-copytext]"); if (!b) return;1236 try { await navigator.clipboard.writeText(b.dataset.copytext); toast("copied"); } catch { /* no clipboard */ }1237 });1238 $("#copynaddr").onclick = async () => { if (!card || !card.naddr) return; await navigator.clipboard.writeText(card.naddr); toast("copied naddr"); };1239 $("#copyembed").onclick = async () => { await navigator.clipboard.writeText('<a href="' + location.origin + '/"><img src="' + location.origin + '/card.svg" alt="' + host + '" width="600" height="315"></a>'); toast("copied embed"); };12401241 // A click on a key copies it; a double-click selects the whole key so the usual copy shortcut takes it too.1242 document.addEventListener("dblclick", (ev) => {1243 const k = ev.target.closest(".key"); if (!k) return;1244 const s = window.getSelection(), r = document.createRange(); r.selectNodeContents(k.querySelector(".full")); s.removeAllRanges(); s.addRange(r);1245 });1246 document.addEventListener("click", async (ev) => {1247 const k = ev.target.closest(".key"); if (!k || ev.detail > 1 || String(window.getSelection())) return;1248 try { await navigator.clipboard.writeText(k.title); toast("copied " + k.dataset.short); } catch { /* no clipboard */ }1249 });1250 // ---- boot ----1251 try { await loadInfo(); } catch { renderHeader(); }1252 await loadFuel();1253 await loadCard();1254 me = localStorage.getItem("me");1255 if (me && await resumeRemote()) { /* a remote session answers for itself when first used */ }1256 else if (me && window.nostr) { try { const pk = await window.nostr.getPublicKey(); if (pk !== me) me = null; } catch { me = null; } }1257 else if (me) me = null;1258 renderHeader();1259 await loadAdmin();1260 await loadPeople();1261})();