clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

organize web UI around embedded templates and custom elements

Dami committed 013d9ee
Move the page template out of Go strings into embedded HTML files, drop
the string-replacement guided forms, and style ids, elements and custom
element names only. Add rpc-form, signed-form, publish-list, json-view
and nostr-key light-DOM elements so signed calls, uploads, list
publishing, JSON results and pubkeys render one way everywhere. Wide
tables scroll inside scroll-box, feeds render through a nostr-event
partial, the home page stops repeating header links, and Status becomes
a manage screen.
commit
013d9eeae5834a944e141db69fa057bda7b0407d
parent
cffc196a901a81bc880bba3c328dad4b247de164
author
Dami <dami.etoile@gmail.com>
15 files changed, +1373 -253
Ainternal/webui/bridge.js166+++++++++++++++++
Ainternal/webui/browse.html52+++++
Minternal/webui/browse_test.go2+-
Ainternal/webui/components.js301++++++++++++++++++++++++++++++
Dinternal/webui/guided_forms.go25--
Ainternal/webui/manage.html235+++++++++++++++++++++++
Ainternal/webui/page.html92+++++++++
Ainternal/webui/public.html169+++++++++++++++++
Minternal/webui/repo.html103+++++-----
Minternal/webui/repo_navigation.go4-
Minternal/webui/signin_test.go2+-
Ainternal/webui/style.css105++++++++++
Minternal/webui/templates.go200++++++++++++++++----
Minternal/webui/webui.go154+++------------
Minternal/webui/webui_test.go16+-
A internal/webui/bridge.js +166 -0
@@ -0,0 +1,166 @@
1+// Signer bridge: session sign-in and sign-out, NIP-07 and NIP-46 signers,2+// Nostr Connect, and the NIP-98 signed fetch that every management call uses.3+// The browser never receives a relay private key; it only asks the signer.4+(() => {5+ "use strict";6+7+ const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];8+ const localPath = path => path && path.startsWith("/") ? (path === root || path.startsWith(root + "/") ? path : root + path) : path;9+ const storagePrefix = "tiny.bunker" + (root || "/");10+ const storage = {11+ uri: storagePrefix + ".uri",12+ key: storagePrefix + ".sk",13+ pubkey: storagePrefix + ".identity",14+ remote: storagePrefix + ".remote"15+ };16+ const status = document.getElementById("signer-status") || document.getElementById("session-status");17+ const say = text => { if (status) status.textContent = text; };18+ const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");19+ const sha256hex = async bytes => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));20+ const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };21+ const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");22+23+ // authorization builds a NIP-98 header for one request body. The body is24+ // hashed exactly once and the same bytes must be sent.25+ async function authorization(url, method, bytes) {26+ if (!window.nostr?.signEvent) throw Error("Connect a Nostr signer first.");27+ const event = await window.nostr.signEvent({28+ kind: 27235,29+ created_at: Math.floor(Date.now() / 1000),30+ tags: [["u", url], ["method", method], ["payload", await sha256hex(bytes)]],31+ content: ""32+ });33+ return "Nostr " + btoa(JSON.stringify(event));34+ }35+36+ async function signedFetch(path, method, body, options = {}) {37+ path = localPath(path);38+ const bytes = toBytes(body);39+ const url = new URL(path, location.href).href;40+ const request = {41+ method,42+ signal: options.signal,43+ headers: {authorization: await authorization(url, method, bytes), "content-type": options.contentType || "application/octet-stream"}44+ };45+ if (method !== "GET" && method !== "HEAD") request.body = bytes;46+ const response = await fetch(path, request);47+ if (!response.ok) throw Error(await response.text());48+ return response;49+ }50+51+ const signedSession = path => signedFetch(path, "POST", "");52+ window.tiny = {root, localPath, sha256hex, authorization, signedFetch};53+ window.tinySignedFetch = signedFetch;54+55+ const signIn = async () => {56+ say("Signing in…");57+ await signedSession("/session");58+ say("Signed in.");59+ location.assign(localPath("/"));60+ };61+62+ const forgetRemote = () => {63+ Object.values(storage).forEach(name => sessionStorage.removeItem(name));64+ sessionStorage.removeItem("tiny.bunker");65+ };66+67+ const rememberRemote = async (uri, key, signer) => {68+ sessionStorage.setItem(storage.uri, uri);69+ sessionStorage.setItem(storage.key, window.NostrSigner.bytesToHex(key));70+ sessionStorage.setItem(storage.pubkey, await signer.getPublicKey());71+ sessionStorage.setItem(storage.remote, JSON.stringify(signer.bp));72+ };73+74+ const setSigner = signer => {75+ window.tinySigner = signer;76+ window.nostr = {signEvent: event => window.tinySigner.signEvent(event)};77+ if (document.getElementById("signer-status")) {78+ say(document.getElementById("session-logout") ? "Signer connected." : "Signer connected. Choose Sign in with connected signer to continue.");79+ }80+ };81+82+ document.getElementById("session-login")?.addEventListener("click", async () => {83+ try { await signIn(); } catch (err) { say("Sign-in error: " + err.message); }84+ });85+86+ document.getElementById("session-logout")?.addEventListener("click", async () => {87+ try {88+ say("Signing out…");89+ const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});90+ if (!response.ok) throw Error(await response.text());91+ forgetRemote();92+ say("Signed out.");93+ location.reload();94+ } catch (err) {95+ say("Sign-out error: " + err.message);96+ }97+ });98+99+ // Nostr Connect: show a QR code and wait for a phone signer such as Amber.100+ document.getElementById("nostrconnect")?.addEventListener("click", async ev => {101+ ev.preventDefault();102+ const key = window.NostrSigner.generateSecretKey();103+ const uri = window.NostrSigner.createNostrConnectURI({104+ clientPubkey: window.NostrSigner.getPublicKey(key),105+ relays: [location.origin.replace(/^http/,"ws")+root],106+ secret: randomHex(),107+ name: "tiny",108+ url: location.origin,109+ perms: ["sign_event:27235"]110+ });111+ const open = document.getElementById("nostrconnect-open");112+ if (open) { open.href = uri; open.hidden = false; }113+ const qr = document.getElementById("signer-qr");114+ if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; }115+ navigator.clipboard?.writeText(uri).catch(() => {});116+ say("Scan the QR code or open the link in your signer.");117+ window.tinyConnect = {key, uri};118+ try {119+ const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri);120+ setSigner(signer);121+ await rememberRemote(uri, key, signer);122+ await signIn();123+ } catch (err) {124+ say("Nostr Connect error: " + err.message);125+ }126+ });127+128+ // Bunker URL: connect to a remote signer the user pastes in.129+ document.getElementById("bunker")?.addEventListener("submit", async ev => {130+ ev.preventDefault();131+ const value = document.getElementById("bunker-url").value.trim();132+ if (!value) return;133+ try {134+ const secret = window.NostrSigner.generateSecretKey();135+ const pointer = await window.NostrSigner.parseBunkerInput(value);136+ const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer);137+ await signer.connect();138+ setSigner(signer);139+ await rememberRemote(value, secret, signer);140+ await signIn();141+ } catch (err) {142+ say("Remote signer error: " + err.message);143+ }144+ });145+146+ // Resume a remote signer stored for this tab so page loads keep working.147+ (async () => {148+ const uri = sessionStorage.getItem(storage.uri);149+ const keyHex = sessionStorage.getItem(storage.key);150+ const expected = sessionStorage.getItem(storage.pubkey);151+ const remote = sessionStorage.getItem(storage.remote);152+ if (!uri || !keyHex) return;153+ try {154+ const key = window.NostrSigner.hexToBytes(keyHex);155+ const pointer = remote ? JSON.parse(remote) : await window.NostrSigner.parseBunkerInput(uri);156+ const signer = window.NostrSigner.BunkerSigner.fromBunker(key, pointer);157+ await signer.connect();158+ const pubkey = await signer.getPublicKey();159+ if (expected && pubkey !== expected) throw Error("Remote signer identity changed");160+ setSigner(signer);161+ } catch (err) {162+ forgetRemote();163+ say("Remote signer resume error: " + err.message);164+ }165+ })();166+})();
A internal/webui/browse.html +52 -0
@@ -0,0 +1,52 @@
1+{{define "repos"}}<main>2+<h2>Repositories</h2>3+<p><a href="/repos">Refresh</a> | <a href="/manage/connect">Connect a repository relay</a></p>4+{{if .Error}}{{template "alert" .Error}}5+{{else if .Feed}}<scroll-box><table id="repo-list">6+<thead><tr><th scope="col">Repository</th><th scope="col">Owner</th><th scope="col">Head</th><th scope="col">Private</th></tr></thead>7+<tbody>8+{{range .Feed}}{{$r := asMap .}}<tr><td><a href="/repo?owner={{urlquery (str (index $r "owner"))}}&repo={{urlquery (str (index $r "identifier"))}}">{{str (index $r "identifier")}}</a></td><td>{{template "nostrKey" (str (index $r "owner"))}}</td><td><code>{{shortID (index $r "head")}}</code></td><td>{{if index $r "private"}}yes{{else}}no{{end}}</td></tr>9+{{end}}</tbody>10+</table></scroll-box>11+{{else}}<p>No repositories were reported.</p>12+{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/repos" $.Query "cursor" .}}">Next page</a></p>13+{{end}}</main>14+{{end}}15+16+{{define "files"}}<main>17+<h2>Files</h2>18+<p><a href="/files">Refresh</a> | <a href="/manage/data">Upload or manage blobs</a></p>19+{{if .Error}}{{template "alert" .Error}}20+{{else if .Feed}}<scroll-box><table id="file-list">21+<thead><tr><th scope="col">Hash</th><th scope="col">Type</th><th scope="col">Size</th><th scope="col">Uploaded</th></tr></thead>22+<tbody>23+{{range .Feed}}{{$f := asMap .}}<tr><td><a href="/file?sha={{str (index $f "sha256")}}"><code>{{str (index $f "sha256")}}</code></a></td><td>{{str (index $f "type")}}</td><td>{{str (index $f "size")}}</td><td>{{str (index $f "uploaded")}}</td></tr>24+{{end}}</tbody>25+</table></scroll-box>26+{{else}}<p>No files were reported.</p>27+{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/files" $.Query "cursor" .}}">Next page</a></p>28+{{end}}</main>29+{{end}}30+31+{{define "file"}}<main>32+<h2>File</h2>33+<p><a href="/files">All files</a></p>34+{{if .Error}}{{template "alert" .Error}}35+{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}36+<p><a href="/files/raw?hash={{str (index $file "sha256")}}">Raw/download</a></p>37+<scroll-box><table id="file-meta">38+<tbody>39+<tr><th scope="row">SHA-256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>40+<tr><th scope="row">Type</th><td>{{str (index $file "type")}}</td></tr>41+<tr><th scope="row">Size</th><td>{{str (index $file "size")}} bytes</td></tr>42+{{with index $file "blossom_uri"}}<tr><th scope="row">Blossom URI</th><td><code>{{.}}</code></td></tr>43+{{end}}</tbody>44+</table></scroll-box>45+{{if index $file "binary"}}<p>This is a binary file. Download it to view its contents.</p>46+{{else}}{{sourceHTML .Event}}47+{{end}}<details>48+<summary>Manage file</summary>49+<rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form>50+</details>51+{{end}}</main>52+{{end}}
M internal/webui/browse_test.go +1 -1
A internal/webui/components.js +301 -0
@@ -0,0 +1,301 @@
1+// Custom elements for the plain HTML pages. Every element renders in the2+// light DOM with document APIs, takes plain lowercase attributes, and carries3+// no styling of its own; page CSS targets the element name directly.4+//5+// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]>6+// One signed NIP-86 management call. Children become the form body; the7+// element appends an <output> status line and a <json-view> result.8+// <signed-form action="…" method="PUT|POST|GET" [download]>9+// A NIP-98 signed upload of one file, or a signed download by name.10+// <publish-list kind="…" tag="…" [scheme="ws"]>11+// Signs a replaceable list event from textarea lines and posts it.12+// <json-view>13+// Renders JSON as tables, lists and definition lists, keeping the raw14+// text in a <details>. Server pages seed it with a <pre>; scripts set .value.15+// Tables sit inside a <scroll-box> so wide rows scroll sideways.16+// <nostr-key hex="…">17+// Shortened key with the full hex as title; click copies it.18+(() => {19+ "use strict";20+21+ const tiny = window.tiny;22+ const el = (name, text) => {23+ const node = document.createElement(name);24+ if (text !== undefined) node.textContent = text;25+ return node;26+ };27+ const isHex64 = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);28+ const isURL = value => typeof value === "string" && /^(https?|wss?):\/\/\S+$/.test(value);29+ const timeKey = /(^|_)(at|time|created|updated|expires|started|finished|since|until)$/;30+ const isTimestamp = (key, value) => Number.isInteger(value) && value > 1e9 && value < 4e9 && timeKey.test(String(key || ""));31+ const isPlain = value => value === null || typeof value !== "object";32+33+ // FormElement wraps light DOM children in a real <form> so labels, Enter34+ // to submit, and required validation work natively, then adds a status line.35+ class FormElement extends HTMLElement {36+ connectedCallback() {37+ if (this.form) return;38+ const form = el("form");39+ form.append(...this.childNodes);40+ this.form = form;41+ this.output = el("output");42+ this.output.setAttribute("role", "status");43+ this.append(form, this.output);44+ form.addEventListener("submit", async event => {45+ event.preventDefault();46+ this.busy(true);47+ try {48+ await this.submit(form);49+ } catch (err) {50+ this.report("Error: " + err.message, true);51+ } finally {52+ this.busy(false);53+ }54+ });55+ }56+57+ busy(on) {58+ this.form.querySelectorAll("button").forEach(button => { button.disabled = on; });59+ }60+61+ report(text, error) {62+ this.output.textContent = text;63+ if (error) this.output.dataset.error = "";64+ else delete this.output.dataset.error;65+ }66+ }67+68+ const list = value => value.split(",").map(item => item.trim()).filter(Boolean);69+ const composers = {70+ member: f => [f.elements["member-pubkey"].value, {name: f.elements["member-name"].value, note: f.elements["member-note"].value, role: f.elements["member-role"].value}],71+ memberInvites: f => [{memberInvites: {depth: Number(f.elements["member-invite-depth"].value), quota: Number(f.elements["member-invite-quota"].value)}}],72+ succession: f => [{heir: f.elements["succession-heir"].value, afterDays: Number(f.elements["succession-days"].value)}],73+ identity: f => [{74+ contact: f.elements["identity-contact"].value,75+ banner: f.elements["identity-banner"].value,76+ postingPolicy: f.elements["identity-posting"].value,77+ privacyPolicy: f.elements["identity-privacy"].value,78+ tags: list(f.elements["identity-tags"].value),79+ languageTags: list(f.elements["identity-languages"].value),80+ relayCountries: list(f.elements["identity-countries"].value)81+ }]82+ };83+84+ class RpcForm extends FormElement {85+ params(form) {86+ const compose = composers[this.getAttribute("compose")];87+ if (compose) return compose(form);88+ let params = [...form.querySelectorAll("[name=param]")].map(input => {89+ try { return JSON.parse(input.value); } catch { return input.value; }90+ });91+ if (this.hasAttribute("json-params") && params.length === 1 && Array.isArray(params[0])) params = params[0];92+ return params;93+ }94+95+ async submit(form) {96+ const method = this.getAttribute("method") || form.elements.method?.value;97+ if (!method) throw Error("Choose a method.");98+ let params = this.params(form);99+ if (method === "claiminvite") {100+ const agree = form.querySelector("[name=termsAgreement]");101+ if (agree && !agree.checked) { this.report("Agree to the relay terms before joining."); return; }102+ const terms = this.getAttribute("terms");103+ if (terms) params = [params[0], await tiny.sha256hex(new TextEncoder().encode(terms))];104+ }105+ const body = JSON.stringify({method, params});106+ this.report("Signing…");107+ const endpoint = tiny.localPath(this.getAttribute("action")||"/manage/rpc");108+ const url = new URL(endpoint, location.href).href;109+ const response = await fetch(endpoint, {110+ method: "POST",111+ headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))},112+ body113+ });114+ const text = await response.text();115+ let payload;116+ try { payload = JSON.parse(text); } catch { payload = undefined; }117+ if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")");118+ const result = payload && "result" in payload ? payload.result : payload ?? text;119+ this.report("Done.");120+ this.show(result);121+ }122+123+ show(result) {124+ if (!this.result) {125+ this.result = el("json-view");126+ this.append(this.result);127+ }128+ this.result.value = result;129+ }130+ }131+132+ class SignedForm extends FormElement {133+ async submit(form) {134+ const action = this.getAttribute("action");135+ const method = (this.getAttribute("method") || "POST").toUpperCase();136+ if (this.hasAttribute("download")) {137+ const name = form.elements.name.value.trim();138+ if (!name) return;139+ this.report("Signing…");140+ const response = await tiny.signedFetch(action + encodeURIComponent(name), "GET", new Uint8Array());141+ const href = URL.createObjectURL(await response.blob());142+ const anchor = el("a");143+ anchor.href = href;144+ anchor.download = name;145+ anchor.click();146+ setTimeout(() => URL.revokeObjectURL(href), 1000);147+ this.report("Downloaded " + name + ".");148+ return;149+ }150+ const file = form.querySelector("input[type=file]")?.files[0];151+ if (!file) { this.report("Choose a file first."); return; }152+ this.report("Signing…");153+ const response = await tiny.signedFetch(action, method, await file.arrayBuffer());154+ this.report("Done: " + await response.text());155+ }156+ }157+158+ class PublishList extends FormElement {159+ async submit(form) {160+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");161+ const tag = this.getAttribute("tag");162+ const scheme = this.getAttribute("scheme");163+ const lines = form.elements.lines.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean)164+ .filter(line => scheme !== "ws" || /^wss?:\/\//i.test(line));165+ const event = await window.nostr.signEvent({166+ kind: Number(this.getAttribute("kind")),167+ created_at: Math.floor(Date.now() / 1000),168+ tags: lines.map(line => [tag, line]),169+ content: ""170+ });171+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event));172+ let result = null;173+ try { result = await response.clone().json(); } catch {}174+ if (result?.error || result?.accepted === false) throw Error(result?.error || result?.message || "The relay rejected the event.");175+ this.report("Published.");176+ }177+ }178+179+ class NostrKey extends HTMLElement {180+ static get observedAttributes() { return ["hex"]; }181+182+ connectedCallback() {183+ this.update();184+ if (this.bound) return;185+ this.bound = true;186+ this.addEventListener("click", () => {187+ navigator.clipboard?.writeText(this.getAttribute("hex") || "").then(() => {188+ const title = this.title;189+ this.title = "Copied";190+ setTimeout(() => { this.title = title; }, 1200);191+ }).catch(() => {});192+ });193+ }194+195+ attributeChangedCallback() { this.update(); }196+197+ update() {198+ const hex = this.getAttribute("hex") || "";199+ this.title = hex;200+ if (!this.textContent.trim()) this.textContent = hex.slice(0, 12);201+ }202+ }203+204+ // JsonView renders any JSON value. Arrays of objects become tables, objects205+ // become definition lists, scalar arrays become lists, and deep nesting206+ // falls back to compact JSON so a response never explodes the page.207+ class JsonView extends HTMLElement {208+ connectedCallback() {209+ if (this.rendered) return;210+ const seed = this.querySelector(":scope > pre");211+ if (!seed) return;212+ try { this.render(JSON.parse(seed.textContent), seed); } catch { /* leave the raw text */ }213+ }214+215+ set value(value) { this.render(value); }216+217+ render(value, seed) {218+ this.rendered = true;219+ const raw = seed || el("pre");220+ raw.textContent = JSON.stringify(value, null, 2);221+ const details = el("details");222+ details.append(el("summary", "JSON"), raw);223+ this.replaceChildren(this.node(value, 0), details);224+ }225+226+ node(value, depth, key) {227+ if (isPlain(value)) return this.scalar(value, key);228+ if (depth >= 3) return el("code", JSON.stringify(value));229+ if (Array.isArray(value)) {230+ if (!value.length) return el("code", "[]");231+ if (value.every(item => item && typeof item === "object" && !Array.isArray(item))) return this.table(value, depth);232+ const items = el("ul");233+ value.forEach(item => { const li = el("li"); li.append(this.node(item, depth + 1)); items.append(li); });234+ return items;235+ }236+ const keys = Object.keys(value);237+ if (!keys.length) return el("code", "{}");238+ const dl = el("dl");239+ keys.forEach(name => {240+ const dd = el("dd");241+ dd.append(this.node(value[name], depth + 1, name));242+ dl.append(el("dt", name), dd);243+ });244+ return dl;245+ }246+247+ table(rows, depth) {248+ const columns = [];249+ rows.forEach(row => Object.keys(row).forEach(name => { if (!columns.includes(name)) columns.push(name); }));250+ const table = el("table");251+ const head = el("tr");252+ columns.forEach(name => { const th = el("th", name); th.setAttribute("scope", "col"); head.append(th); });253+ const thead = el("thead");254+ thead.append(head);255+ const tbody = el("tbody");256+ rows.forEach(row => {257+ const tr = el("tr");258+ columns.forEach(name => {259+ const td = el("td");260+ if (name in row) td.append(isPlain(row[name]) ? this.scalar(row[name], name) : el("code", JSON.stringify(row[name])));261+ tr.append(td);262+ });263+ tbody.append(tr);264+ });265+ table.append(thead, tbody);266+ const box = el("scroll-box");267+ box.append(table);268+ return box;269+ }270+271+ scalar(value, key) {272+ if (value === null || value === undefined) return el("code", "null");273+ if (typeof value === "boolean") return el("code", String(value));274+ if (typeof value === "number") {275+ if (!isTimestamp(key, value)) return el("code", String(value));276+ const time = el("time", new Date(value * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"));277+ time.dateTime = new Date(value * 1000).toISOString();278+ return time;279+ }280+ if (isHex64(value)) {281+ const node = el("nostr-key", value.slice(0, 12));282+ node.setAttribute("hex", value);283+ return node;284+ }285+ if (isURL(value)) {286+ const link = el("a", value);287+ link.href = value;288+ link.rel = "noopener";289+ return link;290+ }291+ if (value.length > 160 || value.includes("\n")) return el("pre", value);292+ return document.createTextNode(value);293+ }294+ }295+296+ customElements.define("rpc-form", RpcForm);297+ customElements.define("signed-form", SignedForm);298+ customElements.define("publish-list", PublishList);299+ customElements.define("nostr-key", NostrKey);300+ customElements.define("json-view", JsonView);301+})();
D internal/webui/guided_forms.go +0 -25
@@ -1,25 +0,0 @@
1-package webui2-3-import "strings"4-5-// guidedPageTemplate adds source-console controls to the compact HTML page6-// without duplicating the generated template or its layout rules.7-func guidedPageTemplate() string {8- page := pageTemplate9- replacements := []struct{ old, new string }{10- {`data-method="deleterelay"><label>Type relay name to confirm <input name="param" value="{{.Slug}}" required>`, `data-method="deleterelay"><label>Type relay name to confirm <input name="param" placeholder="{{.Slug}}" required>`},11- {`data-method="gitstorage"><button>Git storage status</button>`, `data-method="gitstorage"><label>Repository owner <input name="param" required pattern="[0-9a-fA-F]{64}" placeholder="64 hex pubkey"></label><label>Repository identifier <input name="param" required maxlength="256" placeholder="repository name"></label><button>Git storage status</button>`},12- {`data-method="setmember"><label>Pubkey <input name="param" pattern="[0-9a-f]{64}" required></label><label>JSON patch <input name="param" value="{}"></label><button>Save member</button>`, `data-method="setmember" data-compose="member"><label>Pubkey <input name="member-pubkey" pattern="[0-9a-f]{64}" required></label><label>Name <input name="member-name"></label><label>Note <input name="member-note"></label><label>Role <select name="member-role"><option value="member">Member</option><option value="moderator">Moderator</option></select></label><button>Save member</button>`},13- {`data-method="setpolicy"><label>JSON policy patch <textarea name="param">{}</textarea></label><button>Apply policy</button>`, `data-method="setpolicy" data-compose="memberInvites"><label>Member invite depth <input name="member-invite-depth" type="number" min="0" max="100" value="0"></label><label>Live invites per member <input name="member-invite-quota" type="number" min="0" max="1000" value="0"></label><button>Save member invites</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="setpolicy"><label>JSON policy patch <textarea name="param">{}</textarea></label><button>Apply policy</button>`},14- {`data-method="changerelaydescription"><label>Description <textarea name="param">{{.Policy.Description}}</textarea></label><button>Save description</button>`, `data-method="changerelaydescription"><label>Description <textarea name="param">{{.Policy.Description}}</textarea></label><button>Save description</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="changerelayicon"><label>Icon URL <input name="param" type="url" maxlength="2000" value="{{.Policy.Icon}}" placeholder="https://…/icon.png"></label><button>Save icon</button>`},15- {`data-method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button>`, `data-method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="setpolicy" data-compose="identity"><label>Contact <input name="identity-contact" value="{{.Policy.Contact}}" type="email" placeholder="you@example.com"></label><label>Banner URL <input name="identity-banner" value="{{.Policy.Banner}}" type="url" placeholder="https://…/banner.png"></label><label>Posting policy URL <input name="identity-posting" value="{{.Policy.PostingPolicy}}" type="url" placeholder="https://…/posting"></label><label>Privacy policy URL <input name="identity-privacy" value="{{.Policy.PrivacyPolicy}}" type="url" placeholder="https://…/privacy"></label><label>Tags <input name="identity-tags" value="{{join .Policy.Tags ", "}}" placeholder="bitcoin, cats"></label><label>Languages <input name="identity-languages" value="{{join .Policy.LanguageTags ", "}}" placeholder="en, pt-BR"></label><label>Countries <input name="identity-countries" value="{{join .Policy.RelayCountries ", "}}" placeholder="US, DE"></label><button>Save directory identity</button>`},16- {`data-method="disallowkind"><label>Kind <input name="param" type="number" required></label><button>Block kind</button></form>`, `data-method="disallowkind"><label>Kind <input name="param" type="number" required></label><button>Block kind</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="setretention"><label>Kind or null <input name="param" value="null" required></label><label>Keep days <input name="param" type="number" min="0" required></label><button>Save retention</button>`},17- {`data-method="deleterelay"><label>Type relay name to confirm`, `data-method="setsuccession" data-compose="succession"><label>Heir pubkey <input name="succession-heir" pattern="[0-9a-f]{64}" required></label><label>Hand over after days <select name="succession-days"><option value="90">90</option><option value="180" selected>180</option><option value="365">365</option></select></label><button>Set heir</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="clearsuccession"><button>Clear heir</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deleterelay"><label>Type relay name to confirm`},18- }19- for _, replacement := range replacements {20- page = strings.Replace(page, replacement.old, replacement.new, 1)21- }22- old := `let params=[...form.querySelectorAll("[name=param]")].map(x=>{try{return JSON.parse(x.value)}catch{return x.value}});if(form.dataset.jsonParams&&params.length===1&&Array.isArray(params[0]))params=params[0];`23- newCode := `let params=[...form.querySelectorAll("[name=param]")].map(x=>{try{return JSON.parse(x.value)}catch{return x.value}});if(form.dataset.compose==="member")params=[form.elements["member-pubkey"].value,{name:form.elements["member-name"].value,note:form.elements["member-note"].value,role:form.elements["member-role"].value}];if(form.dataset.compose==="memberInvites")params=[{memberInvites:{depth:Number(form.elements["member-invite-depth"].value),quota:Number(form.elements["member-invite-quota"].value)}}];if(form.dataset.compose==="succession")params=[{heir:form.elements["succession-heir"].value,afterDays:Number(form.elements["succession-days"].value)}];if(form.dataset.compose==="identity")params=[{contact:form.elements["identity-contact"].value,banner:form.elements["identity-banner"].value,postingPolicy:form.elements["identity-posting"].value,privacyPolicy:form.elements["identity-privacy"].value,tags:form.elements["identity-tags"].value.split(",").map(x=>x.trim()).filter(Boolean),languageTags:form.elements["identity-languages"].value.split(",").map(x=>x.trim()).filter(Boolean),relayCountries:form.elements["identity-countries"].value.split(",").map(x=>x.trim()).filter(Boolean)}];if(form.dataset.jsonParams&&params.length===1&&Array.isArray(params[0]))params=params[0];`24- return strings.Replace(page, old, newCode, 1)25-}
A internal/webui/manage.html +235 -0
@@ -0,0 +1,235 @@
1+{{define "people"}}<main>2+<h2>People</h2>3+{{template "manageNav" .}}4+<h3>Members</h3>5+<rpc-form method="listmembers"><button>List members</button></rpc-form>6+<rpc-form method="setmember" compose="member">7+<label>Pubkey <input name="member-pubkey" pattern="[0-9a-f]{64}" required></label>8+<label>Name <input name="member-name"></label>9+<label>Note <input name="member-note"></label>10+<label>Role <select name="member-role"><option value="member">Member</option><option value="moderator">Moderator</option></select></label>11+<button>Save member</button>12+</rpc-form>13+<rpc-form method="removemember"><label>Pubkey <input name="param" required></label><button>Remove member</button></rpc-form>14+<rpc-form method="removesubtree"><label>Member pubkey <input name="param" required></label><button>Remove invite subtree</button></rpc-form>15+<h3>Invites</h3>16+<rpc-form method="createinvite">17+<label>TTL seconds <input name="param" type="number" value="259200"></label>18+<label>Maximum uses <input name="param" type="number" value="1"></label>19+<label>Note <input name="param"></label>20+<button>Create invite</button>21+</rpc-form>22+<rpc-form method="listinvites"><button>List invites</button></rpc-form>23+<h3>Claims</h3>24+<rpc-form method="listclaims"><button>List claims</button></rpc-form>25+<rpc-form method="createclaim"><label>JSON claim <input name="param" value="{}"></label><button>Create claim</button></rpc-form>26+<rpc-form method="deleteclaim"><label>Claim code <input name="param" required></label><button>Delete claim</button></rpc-form>27+<h3>Lists</h3>28+<rpc-form method="listallowedpubkeys"><button>Allowed pubkeys</button></rpc-form>29+<rpc-form method="listbannedpubkeys"><button>Banned pubkeys</button></rpc-form>30+</main>31+{{end}}32+33+{{define "moderation"}}<main>34+<h2>Moderation</h2>35+{{template "manageNav" .}}36+<h3>Reports</h3>37+<rpc-form method="listreports"><button>List reports</button></rpc-form>38+<rpc-form method="resolvereport">39+<label>Report ID <input name="param" required></label>40+<label>JSON resolution <input name="param" value="{}"></label>41+<button>Resolve report</button>42+</rpc-form>43+<h3>Events</h3>44+<rpc-form method="listrecentevents"><button>Recent events</button></rpc-form>45+<rpc-form method="searchevents"><label>JSON filter <input name="param" value="{}"></label><button>Search events</button></rpc-form>46+<rpc-form method="banevent"><label>Event ID <input name="param" required></label><button>Hide event</button></rpc-form>47+<rpc-form method="allowevent"><label>Event ID <input name="param" required></label><button>Allow event</button></rpc-form>48+<rpc-form method="deleteevent"><label>Event ID <input name="param" required></label><button>Delete event</button></rpc-form>49+<h3>Pins</h3>50+<rpc-form method="listpins"><button>List pins</button></rpc-form>51+<rpc-form method="pinevent"><label>Event ID or address <input name="param" required></label><button>Pin</button></rpc-form>52+<rpc-form method="unpinevent"><label>Event ID or address <input name="param" required></label><button>Unpin</button></rpc-form>53+<h3>Pubkeys</h3>54+<rpc-form method="banpubkey"><label>Pubkey <input name="param" required></label><button>Ban</button></rpc-form>55+</main>56+{{end}}57+58+{{define "rules"}}<main>59+<h2>Rules</h2>60+{{template "manageNav" .}}61+<h3>Policy</h3>62+<rpc-form method="getpolicy"><button>Load policy</button></rpc-form>63+<rpc-form method="setpolicy" compose="memberInvites">64+<label>Member invite depth <input name="member-invite-depth" type="number" min="0" max="100" value="0"></label>65+<label>Live invites per member <input name="member-invite-quota" type="number" min="0" max="1000" value="0"></label>66+<button>Save member invites</button>67+</rpc-form>68+<rpc-form method="setpolicy"><label>JSON policy patch <textarea name="param">{}</textarea></label><button>Apply policy</button></rpc-form>69+<h3>Kinds</h3>70+<rpc-form method="allowkind"><label>Kind <input name="param" type="number" required></label><button>Allow kind</button></rpc-form>71+<rpc-form method="disallowkind"><label>Kind <input name="param" type="number" required></label><button>Block kind</button></rpc-form>72+<h3>Retention</h3>73+<rpc-form method="setretention">74+<label>Kind or null <input name="param" value="null" required></label>75+<label>Keep days <input name="param" type="number" min="0" required></label>76+<button>Save retention</button>77+</rpc-form>78+</main>79+{{end}}80+81+{{define "identity"}}<main>82+<h2>Identity</h2>83+{{template "manageNav" .}}84+<h3>Profile</h3>85+<rpc-form method="changerelayname"><label>Name <input name="param" value="{{.Policy.Name}}"></label><button>Save name</button></rpc-form>86+<rpc-form method="changerelaydescription"><label>Description <textarea name="param">{{.Policy.Description}}</textarea></label><button>Save description</button></rpc-form>87+<rpc-form method="changerelayicon"><label>Icon URL <input name="param" type="url" maxlength="2000" value="{{.Policy.Icon}}" placeholder="https://…/icon.png"></label><button>Save icon</button></rpc-form>88+<h3>Directory listing</h3>89+<rpc-form method="setpolicy" compose="identity">90+<label>Contact <input name="identity-contact" value="{{.Policy.Contact}}" type="email" placeholder="you@example.com"></label>91+<label>Banner URL <input name="identity-banner" value="{{.Policy.Banner}}" type="url" placeholder="https://…/banner.png"></label>92+<label>Posting policy URL <input name="identity-posting" value="{{.Policy.PostingPolicy}}" type="url" placeholder="https://…/posting"></label>93+<label>Privacy policy URL <input name="identity-privacy" value="{{.Policy.PrivacyPolicy}}" type="url" placeholder="https://…/privacy"></label>94+<label>Tags <input name="identity-tags" value="{{join .Policy.Tags ", "}}" placeholder="bitcoin, cats"></label>95+<label>Languages <input name="identity-languages" value="{{join .Policy.LanguageTags ", "}}" placeholder="en, pt-BR"></label>96+<label>Countries <input name="identity-countries" value="{{join .Policy.RelayCountries ", "}}" placeholder="US, DE"></label>97+<button>Save directory identity</button>98+</rpc-form>99+<h3>Ownership</h3>100+<rpc-form method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button></rpc-form>101+</main>102+{{end}}103+104+{{define "connect"}}<main>105+<h2>Connect</h2>106+{{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p>107+{{end}}{{template "manageNav" .}}108+<h3>Connections</h3>109+<p><button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button></p>110+<scroll-box><table id="connections-preview" aria-live="polite"><tbody><tr><td>No preview loaded.</td></tr></tbody></table></scroll-box>111+<rpc-form method="listconnections"><button>List connections</button></rpc-form>112+<rpc-form method="setconnections"><label>JSON connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form>113+<rpc-form method="listconnectiontemplates"><button>List templates</button></rpc-form>114+<h3>Publish signed relay records</h3>115+<p>These actions open the configured signer and publish owner-authored Nostr records.</p>116+<publish-list kind="10317" tag="g" scheme="ws">117+<label>GRASP relay URLs, one per line <textarea name="lines" placeholder="wss://relay.example&#10;wss://backup.example"></textarea></label>118+<button>Publish GRASP list</button>119+</publish-list>120+<publish-list kind="10063" tag="server">121+<label>Blossom server URLs, one per line <textarea name="lines"></textarea></label>122+<button>Publish server list</button>123+</publish-list>124+<h3>Sign in</h3>125+{{template "signerControls" .}}126+</main>127+{{end}}128+129+{{define "data"}}<main>130+<h2>Data</h2>131+{{template "manageNav" .}}132+<h3>Files</h3>133+<signed-form action="/upload" method="PUT"><label>Upload file <input type="file" name="file" required></label><button>Upload</button></signed-form>134+<rpc-form method="listblobs"><button>List blobs</button></rpc-form>135+<rpc-form method="deleteblob"><label>Blob hash <input name="param" required></label><button>Delete blob</button></rpc-form>136+<rpc-form method="listsites"><button>List sites</button></rpc-form>137+<h3>Storage</h3>138+<rpc-form method="storagestats"><button>Storage stats</button></rpc-form>139+<rpc-form method="gitstorage">140+<label>Repository owner <input name="param" required pattern="[0-9a-fA-F]{64}" placeholder="64 hex pubkey"></label>141+<label>Repository identifier <input name="param" required maxlength="256" placeholder="repository name"></label>142+<button>Git storage status</button>143+</rpc-form>144+<h3>Dumps</h3>145+<rpc-form method="listdumps"><button>List dumps</button></rpc-form>146+<rpc-form method="dumpnow"><button>Write dump</button></rpc-form>147+<signed-form action="/dumps/" method="GET" download><label>Dump name <input name="name" required></label><button>Download dump</button></signed-form>148+<rpc-form method="deletedump"><label>Dump name <input name="param" required></label><button>Delete dump</button></rpc-form>149+<h3>Backups</h3>150+<rpc-form method="listbackups"><button>List backups</button></rpc-form>151+<rpc-form method="backupnow"><button>Create backup</button></rpc-form>152+<signed-form action="/backups/" method="GET" download><label>Backup name <input name="name" required></label><button>Download backup</button></signed-form>153+<signed-form action="/backups/preview" method="POST"><label>Backup archive <input type="file" name="file" accept=".backup.json,application/json" required></label><button>Preview backup</button></signed-form>154+<signed-form action="/backups/restore" method="POST"><label>Backup archive <input type="file" name="file" accept=".backup.json,application/json" required></label><button>Restore backup</button></signed-form>155+<rpc-form method="deletebackup"><label>Backup name <input name="param" required></label><button>Delete backup</button></rpc-form>156+<h3>Import</h3>157+<signed-form action="/import" method="PUT"><label>Event JSONL <input type="file" name="file" accept=".jsonl,.json,application/json" required></label><button>Import events</button></signed-form>158+</main>159+{{end}}160+161+{{define "sync"}}<main>162+<h2>Sync and delivery</h2>163+{{template "manageNav" .}}164+<h3>Jobs</h3>165+<p><button id="watch-jobs" fx-action="/manage/jobs/status" fx-method="get" fx-trigger="click" fx-target="#job-status" fx-swap="innerHTML">Watch job status</button></p>166+<scroll-box><table id="job-status"><tbody><tr><td>Job status is idle.</td></tr></tbody></table></scroll-box>167+<rpc-form method="listjobs"><button>List jobs</button></rpc-form>168+<rpc-form method="addjob"><label>JSON job <input name="param" value="{}"></label><button>Add job</button></rpc-form>169+<rpc-form method="runjob"><label>Job ID <input name="param" required></label><button>Run job</button></rpc-form>170+<rpc-form method="removejob"><label>Job ID <input name="param" required></label><button>Remove job</button></rpc-form>171+<h3>Pull and delivery</h3>172+<rpc-form method="pullfrom">173+<label>Relay URL <input name="param" type="url" required></label>174+<label>JSON filter <input name="param" value="{}"></label>175+<button>Add pull source</button>176+</rpc-form>177+<rpc-form method="pullstatus"><button>Pull status</button></rpc-form>178+<rpc-form method="deliverystatus"><button>Delivery status</button></rpc-form>179+<rpc-form method="backfill"><label>JSON backfill plan <input name="param" value="{}"></label><button>Backfill</button></rpc-form>180+</main>181+{{end}}182+183+{{define "views"}}<main>184+<h2>Views</h2>185+{{template "manageNav" .}}186+<rpc-form method="listviews"><button>List views</button></rpc-form>187+<rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form>188+</main>189+{{end}}190+191+{{define "health"}}<main>192+<h2>Health</h2>193+{{template "manageNav" .}}194+<rpc-form method="stats"><button>Relay stats</button></rpc-form>195+<rpc-form method="listaudit"><label>Before sequence <input name="param" type="number" value="0"></label><button>Audit log</button></rpc-form>196+<rpc-form method="supportedmethods"><button>Supported methods</button></rpc-form>197+</main>198+{{end}}199+200+{{define "status"}}<main>201+<h2>Service status</h2>202+{{template "manageNav" .}}203+{{if .Error}}{{template "alert" .Error}}204+{{else}}{{template "jsonView" .Event}}205+<p><a href="/manage/sync">Jobs and delivery</a> | <a href="/manage/data">Storage and backups</a> | <a href="/manage/health">Relay health</a></p>206+{{end}}</main>207+{{end}}208+209+{{define "owner"}}<main>210+<h2>Owner operations</h2>211+{{template "manageNav" .}}212+<p>Destructive actions ask the signer. The browser never receives or submits a private key.</p>213+<h3>Configuration</h3>214+<rpc-form method="exportconfig"><button>Export configuration</button></rpc-form>215+<rpc-form method="planconfig"><label>Config JSON <textarea name="param">{}</textarea></label><button>Preview configuration</button></rpc-form>216+<rpc-form method="importconfig"><label>Config JSON <textarea name="param">{}</textarea></label><button>Apply configuration</button></rpc-form>217+<rpc-form method="forkrelay"><label>Fork options JSON <textarea name="param">{}</textarea></label><button>Fork relay</button></rpc-form>218+<h3>Succession</h3>219+<rpc-form method="setsuccession" compose="succession">220+<label>Heir pubkey <input name="succession-heir" pattern="[0-9a-f]{64}" required></label>221+<label>Hand over after days <select name="succession-days"><option value="90">90</option><option value="180" selected>180</option><option value="365">365</option></select></label>222+<button>Set heir</button>223+</rpc-form>224+<rpc-form method="clearsuccession"><button>Clear heir</button></rpc-form>225+<h3>Delete</h3>226+<rpc-form method="deleterelay"><label>Type relay name to confirm <input name="param" placeholder="{{.Slug}}" required></label><button>Delete relay</button></rpc-form>227+<h3>Any management method</h3>228+<p>Every retained NIP-86 method remains callable through the signed form bridge. Dedicated tabs supply guided fields for the common ones.</p>229+<rpc-form json-params>230+<label>Method <select name="method">{{range .Methods}}<option>{{.}}</option>{{end}}</select></label>231+<label>JSON parameters <input name="param" value="[]"></label>232+<button>Run method</button>233+</rpc-form>234+</main>235+{{end}}
A internal/webui/page.html +92 -0
@@ -0,0 +1,92 @@
1+{{define "page"}}<!doctype html>2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width,initial-scale=1">6+<title>{{.Title}}</title>7+<style>{{stylesheet}}</style>8+<script src="/fixi.js"></script>9+<script src="/signer.js"></script>10+<script src="/webmcp.js"></script>11+</head>12+<body>13+<table id="layout" aria-label="tiny relay"><tr><td>14+<header>15+{{if .Private}}<h1>Private relay</h1>16+<p>Sign in with a Nostr signer to continue.</p>17+{{else}}<h1><a href="/">{{.Slug}}</a></h1>18+<p>{{.Policy.Description}}</p>19+<p><code>{{.URL}}</code> | identity {{template "nostrKey" .Identity}}</p>20+<nav aria-label="Site">21+<a href="/">Home</a> | <a href="/repos">Repositories</a> | <a href="/files">Files</a> | <a href="/inbox">Inbox</a> | <a href="/outbox">Outbox</a> | <a href="/search">Search</a> | <a href="/manage/people">Manage</a> |22+{{if .Actor}}<a href="/signin" title="{{.Actor}}">{{shortID .Actor}}</a> <button id="session-logout" type="button">Sign out</button>{{else}}<a href="/signin">Sign in</a>{{end}}23+</nav>24+<p id="session-status" role="status"></p>25+{{end}}</header>26+{{if .Private}}{{template "signin" .}}27+{{else if eq .Tab "home"}}{{template "home" .}}28+{{else if eq .Tab "inbox"}}{{template "inbox" .}}29+{{else if eq .Tab "outbox"}}{{template "outbox" .}}30+{{else if eq .Tab "search"}}{{template "search" .}}31+{{else if eq .Tab "articles"}}{{template "articles" .}}32+{{else if eq .Tab "private"}}{{template "private" .}}33+{{else if eq .Tab "chat"}}{{template "chat" .}}34+{{else if eq .Tab "media"}}{{template "media" .}}35+{{else if eq .Tab "site"}}{{template "site" .}}36+{{else if eq .Tab "marmot"}}{{template "marmot" .}}37+{{else if eq .Tab "grasp"}}{{template "grasp" .}}38+{{else if eq .Tab "terms"}}{{template "terms" .}}39+{{else if eq .Tab "people"}}{{template "people" .}}40+{{else if eq .Tab "moderation"}}{{template "moderation" .}}41+{{else if eq .Tab "rules"}}{{template "rules" .}}42+{{else if eq .Tab "identity"}}{{template "identity" .}}43+{{else if eq .Tab "signin"}}{{template "signin" .}}44+{{else if eq .Tab "connect"}}{{template "connect" .}}45+{{else if eq .Tab "data"}}{{template "data" .}}46+{{else if eq .Tab "sync"}}{{template "sync" .}}47+{{else if eq .Tab "views"}}{{template "views" .}}48+{{else if eq .Tab "health"}}{{template "health" .}}49+{{else if eq .Tab "status"}}{{template "status" .}}50+{{else if eq .Tab "owner"}}{{template "owner" .}}51+{{else if eq .Tab "repos"}}{{template "repos" .}}52+{{else if eq .Tab "repo"}}{{template "repo" .}}53+{{else if eq .Tab "files"}}{{template "files" .}}54+{{else if eq .Tab "file"}}{{template "file" .}}55+{{else if eq .Tab "tools"}}{{template "tools" .}}56+{{else if eq .Tab "event"}}{{template "event" .}}57+{{else if eq .Tab "view"}}{{template "view" .}}58+{{else if eq .Tab "invite"}}{{template "invite" .}}59+{{else}}{{template "home" .}}60+{{end}}61+<footer><a href="/.well-known/nostr.json">NIP-05</a> | <a href="/terms">Terms</a></footer>62+</td></tr></table>63+<script>{{script "bridge.js"}}</script>64+<script>{{script "components.js"}}</script>65+</body>66+</html>67+{{end}}68+69+{{/* Shared partials. Each renders one reusable element so pages never repeat markup for the same kind of data. */}}70+71+{{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}}72+73+{{define "nostrKey"}}<nostr-key hex="{{.}}">{{shortID .}}</nostr-key>{{end}}74+75+{{define "jsonView"}}<json-view><pre>{{json .}}</pre></json-view>{{end}}76+77+{{define "nostrEvent"}}{{$e := asMap .}}<nostr-event id="event-{{str (index $e "id")}}">78+<p><a href="/e/{{str (index $e "id")}}"><code>{{shortID (index $e "id")}}</code></a> | kind {{str (index $e "kind")}} | {{template "nostrKey" (str (index $e "pubkey"))}} | <time datetime="{{datetime (index $e "created_at")}}">{{when (index $e "created_at")}}</time></p>79+<pre>{{str (index $e "content")}}</pre>80+<details><summary>Event JSON</summary>{{template "jsonView" .}}</details>81+</nostr-event>{{end}}82+83+{{define "eventList"}}{{if .Feed}}<ol id="events">84+{{range .Feed}}<li>{{template "nostrEvent" .}}</li>85+{{end}}</ol>86+{{else}}<p>No events.</p>87+{{end}}{{end}}88+89+{{define "manageNav"}}<nav id="manage-nav" aria-label="Manage">90+<a href="/manage/people">People</a> | <a href="/manage/moderation">Moderation</a> | <a href="/manage/rules">Rules</a> | <a href="/manage/identity">Identity</a> | <a href="/manage/connect">Connect</a> | <a href="/manage/data">Data</a> | <a href="/manage/sync">Sync</a> | <a href="/manage/views">Views</a> | <a href="/manage/health">Health</a> | <a href="/manage/owner">Owner</a> | <a href="/manage/status">Status</a> | <a href="/tools">Tools</a>91+</nav>92+{{end}}
A internal/webui/public.html +169 -0
@@ -0,0 +1,169 @@
1+{{define "home"}}<main>2+<h2>About this relay</h2>3+<p>Browse notes, repositories and files. Connect your Nostr signer to publish and manage your relay.</p>4+<dl>5+<dt>Reads</dt><dd>{{.Policy.Reads}}</dd>6+<dt>Writes</dt><dd>{{.Policy.Writes}}</dd>7+<dt>Search</dt><dd>{{.Policy.Features.Search}}</dd>8+<dt>Files</dt><dd>{{.Policy.Features.Files}}</dd>9+</dl>10+<p>Relay URL: <code>{{.URL}}</code></p>11+</main>12+{{end}}13+14+{{define "signin"}}<main>15+<h2>{{if .Actor}}Your account{{else}}Sign in{{end}}</h2>16+{{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>17+<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a></p>18+{{else}}<p>Use a browser extension or connect Amber on your phone.</p>19+{{end}}{{template "signerControls" .}}20+</main>21+{{end}}22+23+{{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>24+<h3>Phone signer</h3>25+<p>Scan the QR code with Amber and approve the connection and sign-in request. This page will open your relay when you finish.</p>26+<p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p>27+<p><img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240"></p>28+<p id="signer-status" role="status"></p>29+<details>30+<summary>Use a bunker URL</summary>31+<form id="bunker">32+<label>Bunker URL <input id="bunker-url" type="url" placeholder="bunker://…" required></label>33+<button>Connect remote signer</button>34+</form>35+</details>36+{{end}}37+38+{{define "inbox"}}<main>39+<h2>Inbox</h2>40+<p>Events addressed to this signed-in key, subject to the same private-event gates as websocket queries.</p>41+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}42+</main>43+{{end}}44+45+{{define "outbox"}}<main>46+<h2>Outbox</h2>47+<p>Events authored by this signed-in key.</p>48+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}49+</main>50+{{end}}51+52+{{define "search"}}<main>53+<h2>Search</h2>54+<form id="search" action="/search" method="get">55+<label>Terms <input name="q" type="search" value="{{.Query.Get "q"}}" required></label>56+<button>Search</button>57+</form>58+{{if .Error}}{{template "alert" .Error}}59+{{else if .Feed}}<h3>Results</h3>60+{{template "eventList" .}}61+{{else if .Query.Get "q"}}<p>No matching events.</p>62+{{end}}</main>63+{{end}}64+65+{{define "articles"}}<main>66+<h2>Articles</h2>67+<p>Long-form NIP-23 events published on this relay.</p>68+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}69+<p><a href="/feed.xml">Atom/RSS feed</a> | <a href="/articles.json">JSON feed</a></p>70+</main>71+{{end}}72+73+{{define "event"}}<main>74+<h2>Event</h2>75+<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p>76+{{if .Feed}}{{template "eventList" .}}{{else}}{{template "nostrEvent" .Event}}{{end}}77+</main>78+{{end}}79+80+{{define "view"}}<main>81+<h2>View: {{.View}}</h2>82+{{template "jsonView" .Event}}83+</main>84+{{end}}85+86+{{define "invite"}}<main>87+<h2>Join this relay</h2>88+<p>This invite is claimed with one signature from your Nostr signer. The relay never receives a private key.</p>89+{{if .Policy.JoinTerms}}<h3>Before you join</h3>90+<pre>{{.Policy.JoinTerms}}</pre>91+{{end}}<rpc-form method="claiminvite" action="/api/invites/claim" terms="{{.Policy.JoinTerms}}">92+<input type="hidden" name="param" value="{{.Invite}}">93+{{if .Policy.JoinTerms}}<label><input type="checkbox" name="termsAgreement" required> I agree to these relay terms.</label>94+{{end}}<button>Join relay</button>95+</rpc-form>96+</main>97+{{end}}98+99+{{define "terms"}}<main>100+<h2>Terms</h2>101+<p>{{.Policy.JoinTerms}}</p>102+</main>103+{{end}}104+105+{{define "tools"}}<main>106+<h2>Browser tools</h2>107+<p>Use a browser agent to browse repositories and files, check relay health, manage jobs and create backups. Connect your signer to manage the relay.</p>108+<p id="webmcp-status" role="status">Checking browser support...</p>109+<ul id="webmcp-tools"></ul>110+<p id="webmcp-operation" role="status"></p>111+<details>112+<summary>Enable WebMCP</summary>113+<p>In Chrome, open <code>chrome://flags/#enable-webmcp-testing</code>, enable WebMCP for testing and relaunch. Your usual pages and signed forms work in other browsers.</p>114+</details>115+<script>(() => {116+ const update = () => {117+ const state = window.tinyWebMCP;118+ document.getElementById("webmcp-status").textContent = !state?.supported119+ ? "WebMCP is unavailable in this browser."120+ : state.errors.length ? "Some tools could not register: " + state.errors.join("; ") : state.registered.length + " tools available.";121+ document.getElementById("webmcp-tools").replaceChildren(...(state?.registered || []).map(name => {122+ const item = document.createElement("li");123+ item.textContent = name;124+ return item;125+ }));126+ document.getElementById("webmcp-operation").textContent = state?.lastOperation || "";127+ };128+ window.addEventListener("tiny:webmcp", update);129+ update();130+ window.tinyWebMCP?.ready.then(update);131+})();</script>132+</main>133+{{end}}134+135+{{define "private"}}<main>136+<h2>Private messages</h2>137+<p>Sign in with a Nostr client to view addressed private events.</p>138+</main>139+{{end}}140+141+{{define "chat"}}<main>142+<h2>Chat</h2>143+<p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p>144+</main>145+{{end}}146+147+{{define "media"}}<main>148+<h2>Media</h2>149+<p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p>150+</main>151+{{end}}152+153+{{define "site"}}<main>154+<h2>Sites</h2>155+<p>This relay serves signed site manifests and snapshots when enabled by policy.</p>156+</main>157+{{end}}158+159+{{define "marmot"}}<main>160+<h2>Marmot</h2>161+<p>Marmot group state is served when the feature is enabled.</p>162+</main>163+{{end}}164+165+{{define "grasp"}}<main>166+<h2>GRASP</h2>167+<p>Repository state and relay capabilities are served through the GRASP endpoints.</p>168+</main>169+{{end}}
M internal/webui/repo.html +54 -49
@@ -1,52 +1,57 @@
1-{{define "repo"}}2-<main class="repository">1+{{define "repo"}}<main>32 <p><a href="/repos">All repositories</a></p>4-{{if .Error}}<h2>{{.Query.Get "repo"}}</h2><p role="alert">{{.Error}}</p>5-{{else}}6-{{$repo := asMap .Event}}{{$view := .Query.Get "view"}}7-<section class="repo-head">8- <h2>{{index $repo "identifier"}}</h2>9- <p class="repo-muted">{{if index $repo "private"}}Private{{else}}Public{{end}} repository | Owner <code title="{{index $repo "owner"}}">{{shortID (index $repo "owner")}}</code>{{with index $repo "commit"}} | Commit <code title="{{.}}">{{shortID .}}</code>{{end}}</p>10- <nav class="repo-tabs" aria-label="Repository views">11- <a href="{{repoURL .Query "tree" ""}}"{{if or (eq $view "tree") (eq $view "file")}} aria-current="page"{{end}}>Code</a>12- <a href="{{repoURL .Query "history" ""}}"{{if or (eq $view "history") (eq $view "commit")}} aria-current="page"{{end}}>History</a>13- <a href="{{repoURL .Query "activity" ""}}"{{if eq $view "activity"}} aria-current="page"{{end}}>Activity</a>14- </nav>15- <div class="repo-toolbar">16- <form method="get" action="/repo">17- <input type="hidden" name="owner" value="{{.Query.Get "owner"}}">18- <input type="hidden" name="repo" value="{{.Query.Get "repo"}}">19- <input type="hidden" name="path" value="{{.Query.Get "path"}}">20- <input type="hidden" name="view" value="{{if eq $view "commit"}}tree{{else}}{{$view}}{{end}}">21- <label>Branch or tag <select name="ref"><option value="">Default branch</option>{{range $name,$oid := index $repo "refs"}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select></label>22- <button>Go</button>23- </form>24- <details class="repo-clone"><summary>Clone</summary>{{range index $repo "clone"}}<p><input aria-label="Clone URL" readonly value="{{.}}" onclick="this.select()"></p>{{else}}<p>No clone URL advertised.</p>{{end}}</details>25- </div>3+{{if .Error}}<h2>{{.Query.Get "repo"}}</h2>4+{{template "alert" .Error}}5+{{else}}{{$repo := asMap .Event}}{{$view := .Query.Get "view"}}6+<section id="repo-head">7+<h2>{{str (index $repo "identifier")}}</h2>8+<p><small>{{if index $repo "private"}}Private{{else}}Public{{end}} repository | Owner {{template "nostrKey" (str (index $repo "owner"))}}{{with index $repo "commit"}} | Commit <code title="{{.}}">{{shortID .}}</code>{{end}}</small></p>9+<nav id="repo-tabs" aria-label="Repository views">10+<a href="{{repoURL .Query "tree" ""}}"{{if or (eq $view "tree") (eq $view "file")}} aria-current="page"{{end}}>Code</a>11+<a href="{{repoURL .Query "history" ""}}"{{if or (eq $view "history") (eq $view "commit")}} aria-current="page"{{end}}>History</a>12+<a href="{{repoURL .Query "activity" ""}}"{{if eq $view "activity"}} aria-current="page"{{end}}>Activity</a>13+</nav>14+<form id="repo-ref" method="get" action="/repo">15+<input type="hidden" name="owner" value="{{.Query.Get "owner"}}">16+<input type="hidden" name="repo" value="{{.Query.Get "repo"}}">17+<input type="hidden" name="path" value="{{.Query.Get "path"}}">18+<input type="hidden" name="view" value="{{if eq $view "commit"}}tree{{else}}{{$view}}{{end}}">19+<label>Branch or tag <select name="ref"><option value="">Default branch</option>{{range $name, $oid := index $repo "refs"}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select></label>20+<button>Go</button>21+</form>22+<details id="repo-clone">23+<summary>Clone</summary>24+{{range index $repo "clone"}}<p><input aria-label="Clone URL" readonly value="{{.}}" onclick="this.select()"></p>25+{{else}}<p>No clone URL advertised.</p>26+{{end}}</details>2627 </section>27-{{if or (eq $view "tree") (eq $view "file")}}28-<nav class="repo-breadcrumbs" aria-label="File path">{{range $index,$crumb := repoBreadcrumbs .Query}}{{if $index}} / {{end}}{{if $crumb.URL}}<a href="{{$crumb.URL}}">{{$crumb.Label}}</a>{{else}}<strong>{{$crumb.Label}}</strong>{{end}}{{end}}</nav>29-{{end}}30-{{if eq $view "file"}}31- <div class="file-heading"><strong>{{.Query.Get "path"}}</strong><a href="/repo/raw?owner={{urlquery (.Query.Get "owner")}}&amp;repo={{urlquery (.Query.Get "repo")}}&amp;ref={{urlquery (.Query.Get "ref")}}&amp;path={{urlquery (.Query.Get "path")}}">Raw/download</a></div>32- {{if index $repo "binary"}}<p>This is a binary file. Download it to view its contents.</p>{{else if index $repo "content"}}<div class="source-wrap">{{sourceHTML .Event}}</div>{{else}}<p>This file is empty.</p>{{end}}33-{{else if eq $view "tree"}}34- <table class="repo-tree"><thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Object</th></tr></thead><tbody>35- {{range index $repo "entries"}}{{$entry := asMap .}}<tr><td>{{if eq (index $entry "type") "tree"}}<span class="entry-kind" aria-label="Directory">dir</span> <a href="{{repoURL $.Query "tree" (index $entry "path")}}">{{index $entry "name"}}/</a>{{else if eq (index $entry "type") "commit"}}<span class="entry-kind">submodule</span> {{index $entry "name"}}{{else}}<span class="entry-kind" aria-label="File">file</span> <a href="{{repoURL $.Query "file" (index $entry "path")}}">{{index $entry "name"}}</a>{{end}}</td><td>{{if eq (index $entry "type") "blob"}}{{index $entry "size"}} B{{else}}—{{end}}</td><td><code title="{{index $entry "oid"}}">{{shortID (index $entry "oid")}}</code></td></tr>36- {{else}}<tr><td colspan="3">No published files in this directory.</td></tr>{{end}}37- </tbody></table>38-{{else if eq $view "history"}}39- <table class="repo-history"><thead><tr><th scope="col">Commit</th><th scope="col">Message</th><th scope="col">Author and date</th></tr></thead><tbody>40- {{range index $repo "commits"}}{{$commit := asMap .}}<tr><td><a href="{{repoCommitURL $.Query (index $commit "oid")}}"><code>{{shortID (index $commit "oid")}}</code></a></td><td>{{index $commit "subject"}}</td><td>{{index $commit "author"}}<br><small>{{repoDate (index $commit "time")}}</small></td></tr>{{else}}<tr><td colspan="3">No commits.</td></tr>{{end}}41- </tbody></table>42-{{else if eq $view "commit"}}43- <h3>Commit <code>{{shortID (index $repo "commit")}}</code></h3>44- <p><a href="{{repoURL .Query "tree" ""}}">Browse files at this commit</a></p>45- <div class="source-wrap">{{diffHTML .Event}}</div>46-{{else if eq $view "activity"}}47- {{range index $repo "events"}}{{$event := asMap .}}<article class="repo-activity"><p><a href="/e/{{index $event "id"}}">Event {{shortID (index $event "id")}}</a> | {{repoDate (index $event "created_at")}}</p><pre>{{index $event "content"}}</pre><details><summary>Event details</summary><pre>{{json .}}</pre></details></article>{{else}}<p>No repository activity.</p>{{end}}48-{{end}}49-{{if or (eq $view "tree") (eq $view "history")}}<p class="repo-pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query $view (.Query.Get "path")}}">First page</a>{{end}} {{if hasNextOffset (index $repo "next_offset")}}<a href="{{browsePageURL "/repo" .Query "offset" (index $repo "next_offset")}}">Next page</a>{{end}}</p>{{end}}50-{{end}}51-</main>28+{{if or (eq $view "tree") (eq $view "file")}}<nav id="repo-breadcrumbs" aria-label="File path">{{range $index, $crumb := repoBreadcrumbs .Query}}{{if $index}} / {{end}}{{if $crumb.URL}}<a href="{{$crumb.URL}}">{{$crumb.Label}}</a>{{else}}<strong>{{$crumb.Label}}</strong>{{end}}{{end}}</nav>29+{{end}}{{if eq $view "file"}}<p id="file-heading"><strong>{{.Query.Get "path"}}</strong> <a href="/repo/raw?owner={{urlquery (.Query.Get "owner")}}&amp;repo={{urlquery (.Query.Get "repo")}}&amp;ref={{urlquery (.Query.Get "ref")}}&amp;path={{urlquery (.Query.Get "path")}}">Raw/download</a></p>30+{{if index $repo "binary"}}<p>This is a binary file. Download it to view its contents.</p>31+{{else if index $repo "content"}}{{sourceHTML .Event}}32+{{else}}<p>This file is empty.</p>33+{{end}}{{else if eq $view "tree"}}<scroll-box><table id="repo-tree">34+<thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Object</th></tr></thead>35+<tbody>36+{{range index $repo "entries"}}{{$entry := asMap .}}<tr><td>{{if eq (str (index $entry "type")) "tree"}}<small aria-label="Directory">dir</small> <a href="{{repoURL $.Query "tree" (str (index $entry "path"))}}">{{str (index $entry "name")}}/</a>{{else if eq (str (index $entry "type")) "commit"}}<small>submodule</small> {{str (index $entry "name")}}{{else}}<small aria-label="File">file</small> <a href="{{repoURL $.Query "file" (str (index $entry "path"))}}">{{str (index $entry "name")}}</a>{{end}}</td><td>{{if eq (str (index $entry "type")) "blob"}}{{str (index $entry "size")}} B{{else}}-{{end}}</td><td><code title="{{str (index $entry "oid")}}">{{shortID (index $entry "oid")}}</code></td></tr>37+{{else}}<tr><td colspan="3">No published files in this directory.</td></tr>38+{{end}}</tbody>39+</table></scroll-box>40+{{else if eq $view "history"}}<scroll-box><table id="repo-history">41+<thead><tr><th scope="col">Commit</th><th scope="col">Message</th><th scope="col">Author and date</th></tr></thead>42+<tbody>43+{{range index $repo "commits"}}{{$commit := asMap .}}<tr><td><a href="{{repoCommitURL $.Query (str (index $commit "oid"))}}"><code>{{shortID (index $commit "oid")}}</code></a></td><td>{{str (index $commit "subject")}}</td><td>{{str (index $commit "author")}}<br><small>{{repoDate (index $commit "time")}}</small></td></tr>44+{{else}}<tr><td colspan="3">No commits.</td></tr>45+{{end}}</tbody>46+</table></scroll-box>47+{{else if eq $view "commit"}}<h3>Commit <code>{{shortID (index $repo "commit")}}</code></h3>48+<p><a href="{{repoURL .Query "tree" ""}}">Browse files at this commit</a></p>49+{{diffHTML .Event}}50+{{else if eq $view "activity"}}{{with index $repo "events"}}<ol id="events">51+{{range .}}<li>{{template "nostrEvent" .}}</li>52+{{end}}</ol>53+{{else}}<p>No repository activity.</p>54+{{end}}{{end}}55+{{if or (eq $view "tree") (eq $view "history")}}<p id="repo-pages">{{if .Query.Get "offset"}}<a href="{{repoURL .Query $view (.Query.Get "path")}}">First page</a> {{end}}{{if hasNextOffset (index $repo "next_offset")}}<a href="{{browsePageURL "/repo" .Query "offset" (index $repo "next_offset")}}">Next page</a>{{end}}</p>56+{{end}}{{end}}</main>5257 {{end}}
M internal/webui/repo_navigation.go +0 -4
M internal/webui/signin_test.go +1 -1
A internal/webui/style.css +105 -0
@@ -0,0 +1,105 @@
1+html,body{margin:0;padding:0}2+body{font-family:system-ui,sans-serif;line-height:1.4;color:#202124}3+#layout{border-collapse:collapse;table-layout:fixed;width:960px;margin:0 auto}4+#layout>tbody>tr>td{padding:12px;vertical-align:top;overflow-wrap:anywhere}5+#layout>tbody>tr>td>header{border-bottom:1px solid #bbb}6+#layout>tbody>tr>td>footer{border-top:1px solid #bbb;margin-top:1rem;padding-top:.5rem}7+h1{font-size:1.6rem}8+h2{font-size:1.3rem}9+h3{font-size:1.05rem;margin:1.5rem 0 .5rem;border-bottom:1px solid #e2e2e2}10+input,textarea,button,select{max-width:100%;box-sizing:border-box;font:inherit}11+button{padding:.3rem .65rem}12+textarea{width:100%;min-height:5em;vertical-align:top}13+pre{white-space:pre-wrap;overflow-wrap:anywhere}14+code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}15+[role=alert]{color:#a40000}16+[role=status]{color:#444}17+small{color:#666}18+nav a{white-space:nowrap}19+#manage-nav{margin:.5rem 0 1rem}20+21+/* Data tables inside the page body. Wide tables sit in a scroll-box and22+ scroll sideways instead of squeezing columns; cells never break mid-word. */23+scroll-box{display:block;overflow-x:auto;max-width:100%}24+main table{width:100%;border-collapse:collapse;margin:.5rem 0}25+main th,main td{padding:.35rem .5rem;border-bottom:1px solid #e2e2e2;text-align:left;vertical-align:top;overflow-wrap:normal}26+main th,main td code,main td nostr-key,main td time,main td a{white-space:nowrap}27+main th[scope=row]{width:9em}28+dl{margin:.5rem 0}29+dt{font-weight:600}30+dd{margin:0 0 .35rem 1rem}31+32+/* rpc-form: one signed management call. Labels wrap like inline text so short forms stay on one line. */33+rpc-form,signed-form,publish-list{display:block;margin:.6rem 0}34+rpc-form label,signed-form label,publish-list label{display:inline-block;margin:0 .6rem .35rem 0;vertical-align:top}35+rpc-form label:has(textarea),publish-list label:has(textarea){display:block}36+rpc-form output,signed-form output,publish-list output{display:block;margin:.3rem 0;color:#444}37+rpc-form output[data-error]{color:#a40000}38+39+/* json-view: structured rendering of any JSON payload, with the raw text kept behind a details toggle. */40+json-view{display:block}41+json-view>details>summary{cursor:pointer;color:#666}42+json-view>details>pre{margin:.3rem 0;padding:.5rem;border:1px solid #e2e2e2;background:#fafafa;font-size:13px}43+json-view table{font-size:14px}44+json-view td pre{margin:0;white-space:pre;max-width:40em;overflow:auto}45+json-view dl dl{margin:.2rem 0 .2rem 1rem}46+json-view ul{margin:.2rem 0;padding-left:1.2rem}47+json-view code{overflow-wrap:anywhere}48+49+/* nostr-event: a single event in feeds, activity, and detail pages. */50+#events{list-style:none;margin:0;padding:0}51+nostr-event{display:block;padding:.6rem 0;border-bottom:1px solid #e2e2e2}52+nostr-event>p{margin:0 0 .3rem;color:#555}53+nostr-event>pre{margin:0}54+nostr-event>details{margin-top:.3rem}55+nostr-event>details>summary{cursor:pointer;color:#666}56+nostr-key{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;cursor:copy}57+58+/* Repository browsing. */59+#repo-head{border:1px solid #d0d0d0;padding:12px;background:#fafafa}60+#repo-head h2{margin:0 0 6px}61+#repo-head p{margin:0 0 10px}62+#repo-tabs{display:block;border-bottom:1px solid #bbb;margin:1rem 0}63+#repo-tabs a{display:inline-block;padding:.45rem .7rem;text-decoration:none}64+#repo-tabs a[aria-current=page]{border:1px solid #bbb;border-bottom-color:white;margin-bottom:-1px;background:white;font-weight:600}65+#repo-ref{display:inline-block;margin:0 .5rem 0 0;vertical-align:middle}66+#repo-clone{display:inline-block;vertical-align:middle}67+#repo-clone input{width:40em;font:13px ui-monospace,monospace}68+#repo-breadcrumbs{margin:16px 0;font:14px ui-monospace,monospace}69+#file-heading{padding:8px 12px;margin:0;border:1px solid #ccc;border-bottom:0;background:#f5f5f5}70+#file-heading a{float:right}71+#repo-tree small{display:inline-block;width:5.5em;color:#777;font:12px ui-monospace,monospace}72+#repo-tree td:last-child,#repo-tree th:last-child{font-family:ui-monospace,monospace}73+#repo-pages a{margin-right:16px}74+75+/* source-view and diff-view wrap the tables emitted by the source renderer. */76+source-view,diff-view{display:block;overflow:auto;border:1px solid #ccc;background:#fafafa}77+source-view table,diff-view table{border-collapse:collapse;width:100%;margin:0;font:13px/20px ui-monospace,SFMono-Regular,Consolas,monospace}78+source-view td,diff-view td{padding:0 .75rem;border:0;vertical-align:top;white-space:pre;overflow-wrap:normal;word-break:normal}79+source-view td:first-child,diff-view td:first-child{color:#888;text-align:right;user-select:none;border-right:1px solid #ddd}80+source-view td:first-child a,diff-view td:first-child a{text-decoration:none;color:#777}81+source-view td:last-child,diff-view td:last-child{width:100%}82+source-view tr:target,diff-view tr:target{background:#fff1bc}83+source-view code,diff-view code{font:inherit}84+source-view b{font-weight:600}85+source-view i{font-style:normal;color:#777}86+source-view q{quotes:none;color:#146b2d}87+source-view q::before,source-view q::after{content:none}88+source-view var{font-style:normal;color:#7a3e00}89+source-view tfoot td,diff-view tfoot td{white-space:normal;color:#777;font-family:system-ui,sans-serif}90+diff-view ins,diff-view del{text-decoration:none}91+diff-view tr:has(ins){background:#e9f7e9}92+diff-view tr:has(del){background:#fbeaea}93+diff-view b{font-weight:600;color:#555}94+95+@media(max-width:979px){96+#layout{width:360px;max-width:100%;margin:0}97+#layout>tbody>tr>td{padding:8px}98+nav{line-height:2}99+#repo-tabs{overflow-x:auto}100+#repo-tabs a{white-space:nowrap}101+#repo-tree th:nth-child(3),#repo-tree td:nth-child(3){display:none}102+#repo-clone input{width:100%}103+source-view table,diff-view table{font-size:12px}104+source-view td,diff-view td{padding:0 .5rem}105+}
M internal/webui/templates.go +159 -41
@@ -1,43 +1,161 @@
11 package webui22 3-const pageTemplate = `{{define "page"}}<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>html,body{margin:0;padding:0}body{font-family:system-ui,sans-serif;line-height:1.4;color:#202124}.layout{border-collapse:collapse;table-layout:fixed;width:960px;margin:0 auto}.layout>tbody>tr>td{padding:12px;vertical-align:top;overflow-wrap:anywhere}.layout header,.layout footer{border-bottom:1px solid #bbb}.layout footer{border-top:1px solid #bbb;border-bottom:0}.layout main{width:100%}input,textarea,button,select{max-width:100%;box-sizing:border-box;font:inherit}button{padding:.3rem .65rem}pre{white-space:pre-wrap;overflow-wrap:anywhere}.repo-head{border:1px solid #d0d0d0;padding:12px;background:#fafafa}.repo-head h2{margin:0 0 6px}.repo-head p{margin:0 0 10px}.repo-tabs a{display:inline-block}.repo-breadcrumbs{margin:16px 0;font:14px ui-monospace,monospace}.file-heading{padding:8px 12px;border:1px solid #ccc;border-bottom:0;background:#f5f5f5}.file-heading a{float:right}.source-view tr:target{background:#fff1bc}.source-view code,.diff-view code{font:inherit}.source-line a,.diff-line a{text-decoration:none;color:#777}.entry-kind{display:inline-block;width:3.5em;color:#777;font:12px ui-monospace,monospace}.repo-clone input{width:40em;font:13px ui-monospace,monospace}.repo-pages a{margin-right:16px}.repo-activity{border-bottom:1px solid #ddd}.repo-title{display:block}.repo-title>*{display:inline-block;margin-right:.7rem;vertical-align:baseline}.repo-title h2{margin:.1rem 0}.repo-muted{color:#666}.repo-tabs{display:block;border-bottom:1px solid #bbb;margin:1rem 0}.repo-tabs a{padding:.45rem .7rem;text-decoration:none}.repo-tabs a[aria-current=page]{border:1px solid #bbb;border-bottom-color:white;margin-bottom:-1px;background:white;font-weight:600}.repo-toolbar{display:block;margin:.7rem 0}.repo-toolbar>*{display:inline-block;margin-right:.5rem;vertical-align:middle}.repo-toolbar code{font-family:ui-monospace,monospace}.repo-tree,.repo-history{width:100%;border-collapse:collapse}.repo-tree th,.repo-tree td,.repo-history th,.repo-history td{padding:.35rem .5rem;border-bottom:1px solid #e2e2e2;text-align:left}.repo-tree td:last-child,.repo-tree th:last-child{font-family:ui-monospace,monospace}.source-wrap{overflow:auto;border:1px solid #ccc;background:#fafafa}.source-view{border-collapse:collapse;width:100%;font:13px/20px ui-monospace,SFMono-Regular,Consolas,monospace}.source-view td{padding:0 .75rem;vertical-align:top;white-space:pre;overflow-wrap:normal;word-break:normal}.source-line{color:#888;text-align:right;user-select:none;border-right:1px solid #ddd}.source-code{width:100%}.tok-keyword{font-weight:600}.tok-comment{color:#777}.tok-string{color:#146b2d}.tok-number{color:#7a3e00}.diff-view{overflow:auto;border:1px solid #ccc;width:100%;border-collapse:collapse;font:13px/20px ui-monospace,SFMono-Regular,Consolas,monospace}.diff-view td{padding:0 .75rem;white-space:pre;overflow-wrap:normal;word-break:normal}.diff-add{background:#e9f7e9}.diff-remove{background:#fbeaea}.diff-line{color:#888;text-align:right;user-select:none;border-right:1px solid #ddd}.diff-code{width:100%}.clone-list{display:block}.clone-list>*{display:inline-block;margin:.25rem .5rem .25rem 0}.clone-list code{padding:.25rem .4rem;background:#f1f1f1;overflow-wrap:anywhere}@media(max-width:979px){.layout{width:360px;max-width:100%;margin:0}.layout>tbody>tr>td{padding:8px}nav{line-height:2}.repo-tabs{overflow-x:auto}.repo-tabs a{white-space:nowrap}.repo-tree th:nth-child(3),.repo-tree td:nth-child(3),.repo-tree th:nth-child(4),.repo-tree td:nth-child(4){display:none}.source-view{font-size:12px}.source-view td{padding:0 .5rem}}</style></head><body>4-<table class="layout" aria-label="tiny relay"><tr><td><header>{{if .Private}}<h1>Private relay</h1><p>Sign in with a Nostr signer to continue.</p>{{else}}<h1><a href="/">{{.Slug}}</a></h1><p>{{.Policy.Description}}</p><p><code>{{.URL}}</code> | identity <code>{{.Identity}}</code></p><nav><a href="/">Home</a> | <a href="/repos">Repositories</a> | <a href="/files">Files</a> | <a href="/manage/status">Status</a> | <a href="/inbox">Inbox</a> | <a href="/outbox">Outbox</a> | <a href="/search">Search</a> | <a href="/manage/people">Manage</a> | {{if .Actor}}<a href="/signin" title="{{.Actor}}">{{shortID .Actor}}</a> <button id="session-logout" type="button">Sign out</button>{{else}}<a href="/signin">Sign in</a>{{end}}</nav><p id="session-status" role="status"></p>{{end}}</header>5-{{if .Private}}{{template "privateGate" .}}{{else if eq .Tab "home"}}{{template "home" .}}{{else if eq .Tab "inbox"}}{{template "inbox" .}}{{else if eq .Tab "outbox"}}{{template "outbox" .}}{{else if eq .Tab "search"}}{{template "search" .}}{{else if eq .Tab "articles"}}{{template "articles" .}}{{else if eq .Tab "private"}}{{template "private" .}}{{else if eq .Tab "chat"}}{{template "chat" .}}{{else if eq .Tab "media"}}{{template "media" .}}{{else if eq .Tab "site"}}{{template "site" .}}{{else if eq .Tab "marmot"}}{{template "marmot" .}}{{else if eq .Tab "grasp"}}{{template "grasp" .}}{{else if eq .Tab "terms"}}{{template "terms" .}}{{else if eq .Tab "people"}}{{template "people" .}}{{else if eq .Tab "moderation"}}{{template "moderation" .}}{{else if eq .Tab "rules"}}{{template "rules" .}}{{else if eq .Tab "identity"}}{{template "identity" .}}{{else if eq .Tab "signin"}}{{template "signin" .}}{{else if eq .Tab "connect"}}{{template "connect" .}}{{else if eq .Tab "data"}}{{template "data" .}}{{else if eq .Tab "sync"}}{{template "sync" .}}{{else if eq .Tab "views"}}{{template "views" .}}{{else if eq .Tab "health"}}{{template "health" .}}{{else if eq .Tab "status"}}{{template "status" .}}{{else if eq .Tab "owner"}}{{template "owner" .}}{{else if eq .Tab "repos"}}{{template "repos" .}}{{else if eq .Tab "repo"}}{{template "repo" .}}{{else if eq .Tab "files"}}{{template "files" .}}{{else if eq .Tab "file"}}{{template "file" .}}{{else if eq .Tab "tools"}}{{template "tools" .}}{{else if eq .Tab "event"}}{{template "event" .}}{{else if eq .Tab "view"}}{{template "view" .}}{{else if eq .Tab "invite"}}{{template "invite" .}}{{else}}{{template "home" .}}{{end}}6-<footer><a href="/.well-known/nostr.json">NIP-05</a> | <a href="/terms">Terms</a></footer></td></tr></table>7-<script>(()=>{const root=(location.pathname.match(/^\/r\/[^/]+/)||[""])[0],localPath=p=>p&&p.startsWith("/")?(p===root||p.startsWith(root+"/")?p:root+p):p;const sha256hex=async value=>Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(value))),b=>b.toString(16).padStart(2,"0")).join("");const out=(f,m)=>{let n=f.querySelector("[role=status]");if(!n){n=document.createElement("p");n.setAttribute("role","status");f.append(n)}n.textContent=m;const b=f.querySelector("button");if(b)b.disabled=false};async function auth(url,method,body){if(!window.nostr?.signEvent)throw Error("Connect a Nostr signer first.");const digest=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(body));const hash=Array.from(new Uint8Array(digest),b=>b.toString(16).padStart(2,"0")).join("");return "Nostr "+btoa(JSON.stringify(await window.nostr.signEvent({kind:27235,created_at:Math.floor(Date.now()/1000),tags:[["u",url],["method",method],["payload",hash]],content:""})))}document.querySelectorAll("form.rpc").forEach(form=>form.addEventListener("submit",async ev=>{ev.preventDefault();let params=[...form.querySelectorAll("[name=param]")].map(x=>{try{return JSON.parse(x.value)}catch{return x.value}});if(form.dataset.jsonParams&&params.length===1&&Array.isArray(params[0]))params=params[0];if(form.dataset.method==="claiminvite"){const agree=form.querySelector("[name=termsAgreement]");if(agree&&!agree.checked){out(form,"Agree to the relay terms before joining.");return}if(form.dataset.terms)params=[params[0],await sha256hex(form.dataset.terms)]}const body=JSON.stringify({method:form.dataset.method,params});out(form,"Signing…");try{const endpoint=localPath(form.dataset.endpoint||"/manage/rpc");const url=new URL(endpoint,location.href).href;const response=await fetch(endpoint,{method:"POST",headers:{"content-type":"application/json",authorization:await auth(url,"POST",body)},body});out(form,response.ok?"Done: "+await response.text():"Error: "+await response.text())}catch(err){out(form,"Error: "+err.message)}}))})();</script></body></html>{{end}}8-9-{{define "privateGate"}}{{template "signin" .}}{{end}}10-{{define "signin"}}<main><h2>{{if .Actor}}Your account{{else}}Sign in{{end}}</h2>{{if .Actor}}<p>Signed in as <code>{{.Actor}}</code>.</p><p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a></p>{{else}}<p>Use a browser extension or connect Amber on your phone.</p>{{end}}{{template "signerControls" .}}</main>{{end}}11-{{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p><h3>Phone signer</h3><p>Scan the QR code with Amber and approve the connection and sign-in request. This page will open your relay when you finish.</p><p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p><p><img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240"></p><p id="signer-status" role="status"></p><details><summary>Use a bunker URL</summary><form id="bunker"><label>Bunker URL <input id="bunker-url" type="url" placeholder="bunker://…" required></label><button>Connect remote signer</button></form></details>{{end}}12-{{define "home"}}<main><h2>About this relay</h2><p>Browse notes, repositories and files. Connect your Nostr signer to publish and manage your relay.</p><dl><dt>Reads</dt><dd>{{.Policy.Reads}}</dd><dt>Writes</dt><dd>{{.Policy.Writes}}</dd><dt>Search</dt><dd>{{.Policy.Features.Search}}</dd><dt>Files</dt><dd>{{.Policy.Features.Files}}</dd></dl><p>Relay URL: <code>{{.URL}}</code></p><p><a href="/inbox">Inbox</a> | <a href="/outbox">Outbox</a> | <a href="/search">Search</a> | <a href="/manage/people">Management</a></p></main>{{end}}13-{{define "inbox"}}<main><h2>Inbox</h2><p>Events addressed to this signed-in key, subject to the same private-event gates as websocket queries.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{else}}{{template "eventList" .}}{{end}}</main>{{end}}14-{{define "outbox"}}<main><h2>Outbox</h2><p>Events authored by this signed-in key.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{else}}{{template "eventList" .}}{{end}}</main>{{end}}15-{{define "search"}}<main><h2>Search</h2><form action="/search" method="get"><label>Terms <input name="q" type="search" value="{{.Query.Get "q"}}" required></label><button>Search</button></form>{{if .Error}}<p role="alert">{{.Error}}</p>{{else if .Feed}}<h3>Results</h3>{{template "eventList" .}}{{else if .Query.Get "q"}}<p>No matching events.</p>{{end}}</main>{{end}}16-{{define "manageNav"}}<nav><a href="/manage/people">People</a> | <a href="/manage/moderation">Moderation</a> | <a href="/manage/rules">Rules</a> | <a href="/manage/identity">Identity</a> | <a href="/manage/connect">Connect</a> | <a href="/manage/data">Data</a> | <a href="/manage/sync">Sync</a> | <a href="/manage/views">Views</a> | <a href="/manage/health">Health</a> | <a href="/manage/owner">Owner</a> | <a href="/tools">Tools</a></nav>{{end}}17-{{define "people"}}<main><h2>People</h2>{{template "manageNav" .}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listmembers"><button>List members</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="setmember"><label>Pubkey <input name="param" pattern="[0-9a-f]{64}" required></label><label>JSON patch <input name="param" value="{}"></label><button>Save member</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="removemember"><label>Pubkey <input name="param" required></label><button>Remove member</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="createinvite"><label>TTL seconds <input name="param" type="number" value="259200"></label><label>Maximum uses <input name="param" type="number" value="1"></label><label>Note <input name="param"></label><button>Create invite</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listinvites"><button>List invites</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listclaims"><button>List claims</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="createclaim"><label>JSON claim <input name="param" value="{}"></label><button>Create claim</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deleteclaim"><label>Claim code <input name="param" required></label><button>Delete claim</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="removesubtree"><label>Member pubkey <input name="param" required></label><button>Remove invite subtree</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listallowedpubkeys"><button>Allowed pubkeys</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listbannedpubkeys"><button>Banned pubkeys</button></form></main>{{end}}18-{{define "moderation"}}<main><h2>Moderation</h2>{{template "manageNav" .}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listreports"><button>List reports</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="resolvereport"><label>Report ID <input name="param" required></label><label>JSON resolution <input name="param" value="{}"></label><button>Resolve report</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="banpubkey"><label>Pubkey <input name="param" required></label><button>Ban</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="banevent"><label>Event ID <input name="param" required></label><button>Hide event</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="allowevent"><label>Event ID <input name="param" required></label><button>Allow event</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deleteevent"><label>Event ID <input name="param" required></label><button>Delete event</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="pinevent"><label>Event ID or address <input name="param" required></label><button>Pin</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="unpinevent"><label>Event ID or address <input name="param" required></label><button>Unpin</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listpins"><button>List pins</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listrecentevents"><button>Recent events</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="searchevents"><label>JSON filter <input name="param" value="{}"></label><button>Search events</button></form></main>{{end}}19-{{define "rules"}}<main><h2>Rules</h2>{{template "manageNav" .}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="getpolicy"><button>Load policy</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="setpolicy"><label>JSON policy patch <textarea name="param">{}</textarea></label><button>Apply policy</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="allowkind"><label>Kind <input name="param" type="number" required></label><button>Allow kind</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="disallowkind"><label>Kind <input name="param" type="number" required></label><button>Block kind</button></form></main>{{end}}20-{{define "identity"}}<main><h2>Identity</h2>{{template "manageNav" .}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="changerelayname"><label>Name <input name="param" value="{{.Policy.Name}}"></label><button>Save name</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="changerelaydescription"><label>Description <textarea name="param">{{.Policy.Description}}</textarea></label><button>Save description</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button></form></main>{{end}}21-{{define "connect"}}<main><h2>Connect</h2>{{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p>{{end}}{{template "manageNav" .}}<button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button><pre id="connections-preview" aria-live="polite"></pre><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listconnections"><button>List connections</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="setconnections"><label>JSON connections <textarea name="param">[]</textarea></label><button>Save connections</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listconnectiontemplates"><button>List templates</button></form><h3>Publish signed relay records</h3><p>These actions open the configured signer and publish owner-authored Nostr records.</p><form id="publish-grasp"><label>GRASP relay URLs, one per line <textarea name="relays" placeholder="wss://relay.example\nwss://backup.example"></textarea></label><button>Publish GRASP list</button><output role="status"></output></form><form id="publish-delivery"><label>Blossom server URLs, one per line <textarea name="servers"></textarea></label><button>Publish server list</button><output role="status"></output></form><h3>Sign in</h3>{{template "signerControls" .}}<script>(()=>{const publish=async(f,k,t,c)=>{const o=f.querySelector("output");try{if(!window.nostr?.signEvent)throw Error("Connect a signer first.");const e=await window.nostr.signEvent({kind:k,created_at:Math.floor(Date.now()/1000),tags:t,content:c});const r=await window.tinySignedFetch("/events","POST",JSON.stringify(e));let result=null;try{result=await r.clone().json()}catch{}if(!r.ok||result?.error||result?.accepted===false)throw Error(result?.error||result?.message||"The relay rejected the event.");if(o)o.textContent="Published"}catch(e){if(o)o.textContent="Error: "+e.message}};const g=document.getElementById("publish-grasp");if(g)g.addEventListener("submit",e=>{e.preventDefault();const tags=g.querySelector("[name=relays]").value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean).filter(x=>/^wss?:\/\//i.test(x)).map(x=>["g",x]);publish(g,10317,tags,"")});const d=document.getElementById("publish-delivery");if(d)d.addEventListener("submit",e=>{e.preventDefault();publish(d,10063,d.querySelector("[name=servers]").value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean).map(x=>["server",x]),"")})})();</script></main>{{end}}22-{{define "data"}}<main><h2>Data</h2>{{template "manageNav" .}}<form id="blob-upload"><label>Upload file <input id="blob-upload-input" type="file" required></label><button>Upload</button><output role="status"></output></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="storagestats"><button>Storage stats</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listdumps"><button>List dumps</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="dumpnow"><button>Write dump</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deletedump"><label>Dump name <input name="param" required></label><button>Delete dump</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listbackups"><button>List backups</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="backupnow"><button>Create backup</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deletebackup"><label>Backup name <input name="param" required></label><button>Delete backup</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listblobs"><button>List blobs</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deleteblob"><label>Blob hash <input name="param" required></label><button>Delete blob</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listsites"><button>List sites</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="gitstorage"><button>Git storage status</button></form><h3>Portable data</h3><form id="import-file"><label>Event JSONL <input id="import-input" type="file" accept=".jsonl,.json,application/json"></label><button>Import events</button></form><form id="backup-preview"><label>Backup archive <input id="backup-preview-input" type="file" accept=".backup.json,application/json"></label><button>Preview backup</button></form><form id="backup-restore"><label>Backup archive <input id="backup-restore-input" type="file" accept=".backup.json,application/json"></label><button>Restore backup</button></form><form id="backup-download"><label>Backup name <input id="backup-download-name" required></label><button>Download backup</button></form><form id="dump-download"><label>Dump name <input id="dump-download-name" required></label><button>Download dump</button></form><p id="data-status" role="status"></p></main>{{end}}23-{{define "sync"}}<main><h2>Sync and delivery</h2>{{template "manageNav" .}}<button fx-action="/manage/jobs/status" fx-method="get" fx-trigger="click" fx-target="#job-status" fx-swap="innerHTML" id="watch-jobs">Watch job status</button><table id="job-status"><tbody><tr><td>Job status is idle.</td></tr></tbody></table><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listjobs"><button>List jobs</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="pullfrom"><label>Relay URL <input name="param" type="url" required></label><label>JSON filter <input name="param" value="{}"></label><button>Add pull source</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="addjob"><label>JSON job <input name="param" value="{}"></label><button>Add job</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deliverystatus"><button>Delivery status</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="pullstatus"><button>Pull status</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="runjob"><label>Job ID <input name="param" required></label><button>Run job</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="removejob"><label>Job ID <input name="param" required></label><button>Remove job</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="backfill"><label>JSON backfill plan <input name="param" value="{}"></label><button>Backfill</button></form></main>{{end}}24-{{define "views"}}<main><h2>Views</h2>{{template "manageNav" .}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listviews"><button>List views</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></form></main>{{end}}25-{{define "health"}}<main><h2>Health</h2>{{template "manageNav" .}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="stats"><button>Relay stats</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="listaudit"><label>Before sequence <input name="param" type="number" value="0"></label><button>Audit log</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="supportedmethods"><button>Supported methods</button></form>{{end}}26-{{define "owner"}}<main><h2>Owner operations</h2>{{template "manageNav" .}}<p>Destructive actions ask the signer. The browser never receives or submits a private key.</p><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="exportconfig"><button>Export configuration</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="planconfig"><label>Config JSON <textarea name="param">{}</textarea></label><button>Preview configuration</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="importconfig"><label>Config JSON <textarea name="param">{}</textarea></label><button>Apply configuration</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="forkrelay"><label>Fork options JSON <textarea name="param">{}</textarea></label><button>Fork relay</button></form><form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="deleterelay"><label>Type relay name to confirm <input name="param" value="{{.Slug}}" required></label><button>Delete relay</button></form><h3>Supported management methods</h3><p>Each retained NIP-86 method remains callable through the signed form bridge while its dedicated tab supplies guided fields.</p>{{range .Methods}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-json-params="true" data-method="{{.}}"><details><summary>{{.}}</summary><label>JSON parameters <input name="param" value="[]"></label><button>Run {{.}}</button></details></form>{{end}}</main>{{end}}27-{{define "event"}}<main><h2>Event</h2><p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p><pre>{{json .Event}}</pre></main>{{end}}28-{{define "view"}}<main><h2>View: {{.View}}</h2><pre>{{json .Event}}</pre></main>{{end}}29-{{define "invite"}}<main><h2>Join this relay</h2><p>This invite is claimed with one signature from your Nostr signer. The relay never receives a private key.</p>{{if .Policy.JoinTerms}}<h3>Before you join</h3><pre>{{.Policy.JoinTerms}}</pre>{{end}}<form class="rpc" on-submit="this.querySelector('button').disabled=true" data-method="claiminvite" data-terms="{{.Policy.JoinTerms}}" data-endpoint="/api/invites/claim"><input type="hidden" name="param" value="{{.Invite}}">{{if .Policy.JoinTerms}}<label><input type="checkbox" name="termsAgreement" required> I agree to these relay terms.</label>{{end}}<button>Join relay</button></form></main>{{end}}30-{{define "articles"}}<main><h2>Articles</h2><p>Long-form NIP-23 events published on this relay.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{else}}{{template "eventList" .}}{{end}}<p><a href="/feed.xml">Atom/RSS feed</a> | <a href="/articles.json">JSON feed</a></p></main>{{end}}31-{{define "eventList"}}{{if .Feed}}<ol>{{range .Feed}}<li><pre>{{json .}}</pre></li>{{end}}</ol>{{else}}<p>No events.</p>{{end}}{{end}}32-{{define "private"}}<main><h2>Private messages</h2><p>Sign in with a Nostr client to view addressed private events.</p></main>{{end}}33-{{define "chat"}}<main><h2>Chat</h2><p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p></main>{{end}}34-{{define "media"}}<main><h2>Media</h2><p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p></main>{{end}}35-{{define "site"}}<main><h2>Sites</h2><p>This relay serves signed site manifests and snapshots when enabled by policy.</p></main>{{end}}36-{{define "marmot"}}<main><h2>Marmot</h2><p>Marmot group state is served when the feature is enabled.</p></main>{{end}}37-{{define "grasp"}}<main><h2>GRASP</h2><p>Repository state and relay capabilities are served through the GRASP endpoints.</p></main>{{end}}38-{{define "repos"}}<main><h2>Repositories</h2><p><a href="/repos">Refresh</a> | <a href="/connect">Connect a repository relay</a></p>{{if .Error}}<p role="alert">{{.Error}}</p>{{else if .Feed}}<table><thead><tr><th>Repository</th><th>Owner</th><th>Head</th><th>Private</th></tr></thead><tbody>{{range .Feed}}{{with $r := asMap .}}<tr><td><a href="/repo?owner={{urlquery (printf "%v" (index $r "owner"))}}&repo={{urlquery (printf "%v" (index $r "identifier"))}}">{{index $r "identifier"}}</a></td><td>{{index $r "owner"}}</td><td><code>{{index $r "head"}}</code></td><td>{{index $r "private"}}</td></tr>{{end}}{{end}}</tbody></table>{{else}}<p>No repositories were reported.</p>{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/repos" $.Query "cursor" .}}">Next page</a></p>{{end}}</main>{{end}}39-{{define "files"}}<main><h2>Files</h2><p><a href="/files">Refresh</a> | <a href="/manage/data">Upload or manage blobs</a></p>{{if .Error}}<p role="alert">{{.Error}}</p>{{else if .Feed}}<table><thead><tr><th>Hash</th><th>Type</th><th>Size</th><th>Uploaded</th></tr></thead><tbody>{{range .Feed}}<tr><td><a href="/file?sha={{index (asMap .) "sha256"}}">{{index (asMap .) "sha256"}}</a></td><td>{{index (asMap .) "type"}}</td><td>{{index (asMap .) "size"}}</td><td>{{index (asMap .) "uploaded"}}</td></tr>{{end}}</tbody></table>{{else}}<p>No files were reported.</p>{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/files" $.Query "cursor" .}}">Next page</a></p>{{end}}</main>{{end}}40-{{define "file"}}<main><h2>File</h2><p><a href="/files">All files</a></p>{{if .Error}}<p role="alert">{{.Error}}</p>{{else}}{{$file := asMap .Event}}<p><a href="/files/raw?hash={{index $file "sha256"}}">Raw/download</a></p><table class="repo-history"><tbody><tr><th>SHA-256</th><td><code>{{index $file "sha256"}}</code></td></tr><tr><th>Type</th><td>{{index $file "type"}}</td></tr><tr><th>Size</th><td>{{index $file "size"}} bytes</td></tr>{{with index $file "blossom_uri"}}<tr><th>Blossom URI</th><td><code>{{.}}</code></td></tr>{{end}}</tbody></table>{{if index $file "binary"}}<p>This is a binary file. Download it to view its contents.</p>{{else}}<div class="source-wrap">{{sourceHTML .Event}}</div>{{end}}<details><summary>Manage file</summary><form class="rpc" data-method="deleteblob"><input type="hidden" name="param" value="{{if .Query.Get "hash"}}{{.Query.Get "hash"}}{{else}}{{.Query.Get "sha"}}{{end}}"><button>Delete file</button></form></details>{{end}}</main>{{end}}41-{{define "status"}}<main><h2>Service status</h2>{{template "manageNav" .}}{{if .Error}}<p role="alert">{{.Error}}</p>{{else}}<pre>{{json .Event}}</pre><p><a href="/manage/sync">Jobs and delivery</a> | <a href="/manage/data">Storage and backups</a> | <a href="/manage/health">Relay health</a></p>{{end}}</main>{{end}}42-{{define "tools"}}<main><h2>Browser tools</h2><p>Use a browser agent to browse repositories and files, check relay health, manage jobs and create backups. Connect your signer to manage the relay.</p><p id="webmcp-status" role="status">Checking browser support...</p><ul id="webmcp-tools"></ul><p id="webmcp-operation" role="status"></p><details><summary>Enable WebMCP</summary><p>In Chrome, open <code>chrome://flags/#enable-webmcp-testing</code>, enable WebMCP for testing and relaunch. Your usual pages and signed forms work in other browsers.</p></details><script>(()=>{const update=()=>{const s=window.tinyWebMCP;document.getElementById("webmcp-status").textContent=!s?.supported?"WebMCP is unavailable in this browser.":s.errors.length?"Some tools could not register: "+s.errors.join("; "):s.registered.length+" tools available.";const list=document.getElementById("webmcp-tools");list.replaceChildren(...(s?.registered||[]).map(name=>{const li=document.createElement("li");li.textContent=name;return li}));document.getElementById("webmcp-operation").textContent=s?.lastOperation||"";};window.addEventListener("tiny:webmcp",update);update();window.tinyWebMCP?.ready.then(update)})();</script></main>{{end}}{{define "terms"}}<main><h2>Terms</h2><p>{{.Policy.JoinTerms}}</p></main>{{end}}43-`3+import (4+ "embed"5+ "encoding/json"6+ "fmt"7+ "html/template"8+ "net/url"9+ "strings"10+ "time"11+)12+13+// templateFS holds every page template. page.html owns the shell and the14+// shared partials; the other files each define one group of tabs.15+//16+//go:embed page.html public.html manage.html browse.html repo.html17+var templateFS embed.FS18+19+// styleCSS is inlined into every page so the UI needs no extra request and20+// works unchanged under a tenant path prefix. It styles ids, elements and the21+// custom element names only; templates never carry class attributes.22+//23+//go:embed style.css24+var styleCSS string25+26+// bridgeJS is the signer bridge and componentsJS defines the custom27+// elements. Both are inlined at the end of the body in that order.28+//29+//go:embed bridge.js30+var bridgeJS string31+32+//go:embed components.js33+var componentsJS string34+35+func parseTemplates() (*template.Template, error) {36+ scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS}37+ funcs := template.FuncMap{38+ "stylesheet": func() template.CSS { return template.CSS(styleCSS) },39+ "script": func(name string) (template.JS, error) {40+ source, ok := scripts[name]41+ if !ok {42+ return "", fmt.Errorf("unknown script %q", name)43+ }44+ return template.JS(source), nil45+ },46+ "json": func(value any) string {47+ encoded, err := json.Marshal(value)48+ if err != nil {49+ return "null"50+ }51+ return string(encoded)52+ },53+ "join": strings.Join,54+ "urlquery": url.QueryEscape,55+ "asMap": valueMap,56+ "str": plainString,57+ "datetime": datetime,58+ "when": when,59+ "selectedView": func(query url.Values, view string) string {60+ if query.Get("view") == view || (query.Get("view") == "" && view == "tree") {61+ return " selected"62+ }63+ return ""64+ },65+ "repoCommitURL": repoCommitURL,66+ "hasNextOffset": hasNextOffset,67+ "repoURL": repoURL,68+ "repoBreadcrumbs": repoBreadcrumbs,69+ "browsePageURL": browsePageURL,70+ "shortID": shortID,71+ "repoDate": repoDate,72+ "sourceHTML": sourceHTML,73+ "diffHTML": diffHTML,74+ }75+ tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")76+ if err != nil {77+ return nil, fmt.Errorf("parse web templates: %w", err)78+ }79+ return tmpl, nil80+}81+82+func valueMap(value any) map[string]any {83+ if result, ok := value.(map[string]any); ok {84+ return result85+ }86+ encoded, _ := json.Marshal(value)87+ var result map[string]any88+ _ = json.Unmarshal(encoded, &result)89+ return result90+}91+92+// plainString prints a decoded JSON value for display: nil becomes empty,93+// whole floats print without a fraction, and everything else uses fmt.94+func plainString(value any) string {95+ switch value := value.(type) {96+ case nil:97+ return ""98+ case string:99+ return value100+ case float64:101+ if value == float64(int64(value)) {102+ return fmt.Sprintf("%d", int64(value))103+ }104+ return fmt.Sprintf("%v", value)105+ default:106+ return fmt.Sprint(value)107+ }108+}109+110+// datetime renders a Unix timestamp as UTC text suitable for a time element.111+func datetime(value any) string {112+ seconds := unixSeconds(value)113+ if seconds <= 0 {114+ return ""115+ }116+ return time.Unix(seconds, 0).UTC().Format("2006-01-02T15:04:05Z")117+}118+119+// when renders a Unix timestamp as short UTC text for people to read.120+func when(value any) string {121+ seconds := unixSeconds(value)122+ if seconds <= 0 {123+ return ""124+ }125+ return time.Unix(seconds, 0).UTC().Format("2006-01-02 15:04 UTC")126+}127+128+func unixSeconds(value any) int64 {129+ switch value := value.(type) {130+ case float64:131+ return int64(value)132+ case int64:133+ return value134+ case int:135+ return int64(value)136+ }137+ return 0138+}139+140+// tableRows renders escaped table head and body markup for fragments and141+// streams that swap into an existing table element.142+func tableRows(headers []string, rows [][]string) string {143+ var out strings.Builder144+ out.WriteString(`<thead><tr>`)145+ for _, header := range headers {146+ out.WriteString(`<th scope="col">` + template.HTMLEscapeString(header) + `</th>`)147+ }148+ out.WriteString(`</tr></thead><tbody>`)149+ if len(rows) == 0 {150+ out.WriteString(`<tr><td colspan="` + fmt.Sprint(len(headers)) + `">No entries.</td></tr>`)151+ }152+ for _, row := range rows {153+ out.WriteString(`<tr>`)154+ for _, cell := range row {155+ out.WriteString(`<td>` + template.HTMLEscapeString(cell) + `</td>`)156+ }157+ out.WriteString(`</tr>`)158+ }159+ out.WriteString(`</tbody>`)160+ return out.String()161+}
M internal/webui/webui.go +30 -124
@@ -82,54 +82,9 @@ func New(backend Backend, options Options) (*App, error) {
8282 if backend == nil {8383 return nil, fmt.Errorf("webui backend is required")8484 }85- page := guidedPageTemplate() + repoTemplate86- tmpl, err := template.New("webui").Funcs(template.FuncMap{87- "json": func(value any) string {88- encoded, encodeErr := json.Marshal(value)89- if encodeErr != nil {90- return "null"91- }92- return string(encoded)93- },94- "join": strings.Join,95- "urlquery": url.QueryEscape,96- "asMap": func(value any) map[string]any {97- if result, ok := value.(map[string]any); ok {98- return result99- }100- encoded, _ := json.Marshal(value)101- var result map[string]any102- _ = json.Unmarshal(encoded, &result)103- return result104- },105- "lineRows": func(value any) []sourceLine {106- m := valueMap(value)107- content, _ := m["content"].(string)108- parts := strings.Split(content, "\n")109- rows := make([]sourceLine, 0, len(parts))110- for number, text := range parts {111- rows = append(rows, sourceLine{Number: number + 1, Text: text})112- }113- return rows114- },115- "selectedView": func(query url.Values, view string) string {116- if query.Get("view") == view || (query.Get("view") == "" && view == "tree") {117- return " selected"118- }119- return ""120- },121- "repoCommitURL": repoCommitURL,122- "hasNextOffset": hasNextOffset,123- "repoURL": repoURL,124- "repoBreadcrumbs": repoBreadcrumbs,125- "browsePageURL": browsePageURL,126- "shortID": shortID,127- "repoDate": repoDate,128- "sourceHTML": sourceHTML,129- "diffHTML": diffHTML,130- }).Parse(page)85+ tmpl, err := parseTemplates()13186 if err != nil {132- return nil, fmt.Errorf("parse web templates: %w", err)87+ return nil, err13388 }13489 qr := options.QR13590 if qr == nil {
@@ -138,21 +93,6 @@ func New(backend Backend, options Options) (*App, error) {
13893 return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl}, nil13994 }14095 141-type sourceLine struct {142- Number int143- Text string144-}145-146-func valueMap(value any) map[string]any {147- if result, ok := value.(map[string]any); ok {148- return result149- }150- encoded, _ := json.Marshal(value)151- var result map[string]any152- _ = json.Unmarshal(encoded, &result)153- return result154-}155-15696 func defaultQR(text string) ([]byte, error) {15797 code, err := qrcode.New(text, qrcode.Medium)15898 if err != nil {
@@ -335,17 +275,12 @@ func jobsFragment(result any, queryErr error) string {
335275 if queryErr != nil {336276 return `<tbody><tr><td role="status">` + template.HTMLEscapeString(queryErr.Error()) + `</td></tr></tbody>`337277 }338- encoded, _ := json.Marshal(result)339- var rows []any340- _ = json.Unmarshal(encoded, &rows)341- var out strings.Builder342- out.WriteString(`<thead><tr><th>Job</th><th>Status</th><th>Updated</th></tr></thead><tbody>`)343- for _, row := range rows {278+ rows := make([][]string, 0)279+ for _, row := range browseRows(result) {344280 values, _ := row.(map[string]any)345- out.WriteString(`<tr><td>` + template.HTMLEscapeString(fmt.Sprint(nestedValue(values, "spec", "id"))) + `</td><td>` + template.HTMLEscapeString(fmt.Sprint(firstValue(values, "phase", "status"))) + `</td><td>` + template.HTMLEscapeString(fmt.Sprint(firstValue(values, "finished", "started"))) + `</td></tr>`)281+ rows = append(rows, []string{plainString(nestedValue(values, "spec", "id")), plainString(firstValue(values, "phase", "status")), plainString(firstValue(values, "finished", "started"))})346282 }347- out.WriteString(`</tbody>`)348- return out.String()283+ return tableRows([]string{"Job", "Status", "Updated"}, rows)349284 }350285 351286 func nestedValue(values map[string]any, keys ...string) any {
@@ -455,18 +390,14 @@ func (a *App) connectFragment(writer http.ResponseWriter, request *http.Request)
455390 http.Error(writer, "connections unavailable", http.StatusNotFound)456391 return457392 }458- encoded, _ := json.Marshal(result)459- var rows []any460- _ = json.Unmarshal(encoded, &rows)461- var out strings.Builder462- out.WriteString(`<table id="connections-preview"><thead><tr><th>Label</th><th>Relay</th><th>Status</th></tr></thead><tbody>`)463- for _, row := range rows {393+ rows := make([][]string, 0)394+ for _, row := range browseRows(result) {464395 values, _ := row.(map[string]any)465- out.WriteString(`<tr><td>` + template.HTMLEscapeString(fmt.Sprint(firstValue(values, "label", "title", "name", "template"))) + `</td><td>` + template.HTMLEscapeString(fmt.Sprint(firstValue(values, "url", "href", "relay"))) + `</td><td>` + template.HTMLEscapeString(fmt.Sprint(firstValue(values, "status", "visibility"))) + `</td></tr>`)396+ rows = append(rows, []string{plainString(firstValue(values, "label", "title", "name", "template")), plainString(firstValue(values, "url", "href", "relay")), plainString(firstValue(values, "status", "visibility"))})466397 }467- out.WriteString(`</tbody></table>`)398+ fragment := `<table id="connections-preview" aria-live="polite">` + tableRows([]string{"Label", "Relay", "Status"}, rows) + `</table>`468399 writer.Header().Set("content-type", "text/html; charset=utf-8")469- _, _ = writer.Write([]byte(out.String()))400+ _, _ = writer.Write([]byte(fragment))470401 }471402 472403 func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {
@@ -491,7 +422,11 @@ func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {
491422 http.NotFound(writer, request)492423 return493424 }494- a.renderData(writer, request, PageData{Event: result, View: identifier, Tab: "event"})425+ data := PageData{Event: result, View: identifier, Tab: "event"}426+ if address {427+ data.Feed = browseRows(result)428+ }429+ a.render(writer, request, data)495430 }496431 497432 func (a *App) viewPage(writer http.ResponseWriter, request *http.Request) {
@@ -566,7 +501,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
566501 params = append(params, raw)567502 result, err := a.backend.Query(request.Context(), method, params, actor)568503 if err != nil {569- a.renderData(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})504+ a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})570505 return571506 }572507 pageQuery := request.URL.Query()
@@ -574,7 +509,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
574509 pageQuery.Set("view", "tree")575510 }576511 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}577- a.renderData(writer, request, data)512+ a.render(writer, request, data)578513 }579514 580515 func browseTab(path string) string {
@@ -613,10 +548,12 @@ func browseRows(value any) []any {
613548 614549 func (a *App) invitePage(writer http.ResponseWriter, request *http.Request) {615550 code := strings.TrimPrefix(request.URL.Path, "/invite/")616- a.renderData(writer, request, PageData{Invite: code, Tab: "invite"})551+ a.render(writer, request, PageData{Invite: code, Tab: "invite"})617552 }618553 619-func (a *App) renderData(writer http.ResponseWriter, request *http.Request, data PageData) {554+// render fills the shared page data, applies private relay masking, and555+// writes the page. Every HTML response goes through here.556+func (a *App) render(writer http.ResponseWriter, request *http.Request, data PageData) {620557 actor, _ := a.resolveActor(request)621558 if data.Title == "" {622559 data.Title = a.backend.Slug()
@@ -624,6 +561,9 @@ func (a *App) renderData(writer http.ResponseWriter, request *http.Request, data
624561 data.URL, data.Slug, data.Identity, data.Policy, data.Actor, data.Base = a.backend.URL(), a.backend.Slug(), a.backend.Identity(), a.backend.Policy(), actor, requestPrefix(request)625562 data.Owner = actor != "" && actor == data.Policy.Owner626563 data.Methods = supportedMethods564+ if data.Query == nil {565+ data.Query = request.URL.Query()566+ }627567 a.privatePageData(&data, request, actor)628568 writer.Header().Set("Cache-Control", "private, no-store")629569 writer.Header().Set("content-type", "text/html; charset=utf-8")
@@ -632,10 +572,7 @@ func (a *App) renderData(writer http.ResponseWriter, request *http.Request, data
632572 http.Error(writer, "render page: "+err.Error(), http.StatusInternalServerError)633573 return634574 }635- html := strings.Replace(rendered.String(), "</head>", "<script src=\"/fixi.js\"></script><script src=\"/signer.js\"></script><script src=\"/webmcp.js\"></script></head>", 1)636- html = strings.Replace(html, "</body>", "<script>"+signerBridgeJS+"</script></body>", 1)637- html = injectBase(html, data.Base)638- _, _ = writer.Write([]byte(html))575+ _, _ = writer.Write([]byte(injectBase(rendered.String(), data.Base)))639576 }640577 641578 func identityNpub(identity string) string {
@@ -716,11 +653,7 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
716653 if err != nil {717654 actor = ""718655 }719- data := PageData{Title: a.backend.Slug(), URL: a.backend.URL(), Slug: a.backend.Slug(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor, Tab: tab, Methods: supportedMethods, Base: requestPrefix(request)}720- data.Query = request.URL.Query()721- data.Owner = actor != "" && actor == data.Policy.Owner722- a.privatePageData(&data, request, actor)723- writer.Header().Set("Cache-Control", "private, no-store")656+ data := PageData{Tab: tab, Query: request.URL.Query()}724657 if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" {725658 feed, feedErr := a.publicFeed(request.Context(), tab, actor, request.URL.Query())726659 if feedErr != nil {
@@ -730,37 +663,10 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
730663 }731664 }732665 if tab != "" {733- data.Title = tab + " | " + data.Slug666+ data.Title = tab + " | " + a.backend.Slug()734667 }735- writer.Header().Set("content-type", "text/html; charset=utf-8")736- var rendered bytes.Buffer737- if err := a.tmpl.ExecuteTemplate(&rendered, "page", data); err != nil {738- http.Error(writer, "render page: "+err.Error(), http.StatusInternalServerError)739- return740- }741- html := strings.Replace(rendered.String(), "</head>", "<script src=\"/fixi.js\"></script><script src=\"/signer.js\"></script><script src=\"/webmcp.js\"></script></head>", 1)742- html = strings.Replace(html, "</body>", "<script>"+signerBridgeJS+"</script></body>", 1)743- html = injectBase(html, data.Base)744- _, _ = writer.Write([]byte(html))745-}746-747-const signerBridgeJS = `(()=>{748-const root=(location.pathname.match(/^\/r\/[^/]+/)||[""])[0],localPath=p=>p&&p.startsWith("/")?(p===root||p.startsWith(root+"/")?p:root+p):p;749-const status=document.getElementById("signer-status")||document.getElementById("session-status"), qr=document.getElementById("signer-qr"),storagePrefix="tiny.bunker"+(root||"/"),storageURI=storagePrefix+".uri",storageKey=storagePrefix+".sk",storagePubkey=storagePrefix+".identity",storageRemote=storagePrefix+".remote";750-const say=(text)=>{if(status)status.textContent=text};751-const random=()=>{const b=new Uint8Array(32);crypto.getRandomValues(b);return [...b].map(x=>x.toString(16).padStart(2,"0")).join("")};752-const signedSession=async(path,method="POST")=>{path=localPath(path);if(!window.nostr?.signEvent)throw Error("Connect a Nostr signer first.");const body="";const digest=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(body));const hash=Array.from(new Uint8Array(digest),b=>b.toString(16).padStart(2,"0")).join("");const url=new URL(path,location.href).href;const event=await window.nostr.signEvent({kind:27235,created_at:Math.floor(Date.now()/1000),tags:[["u",url],["method",method],["payload",hash]],content:""});const response=await fetch(path,{method,headers:{authorization:"Nostr "+btoa(JSON.stringify(event))}});if(!response.ok)throw Error(await response.text());return response};753-const signedFetch=async(path,method,body,options={})=>{const root=(location.pathname.match(/^\/r\/[^/]+/)||[""])[0];if(path.startsWith("/")&&! (path===root||path.startsWith(root+"/")))path=root+path;if(!window.nostr?.signEvent)throw Error("Connect a Nostr signer first.");const bytes=body instanceof ArrayBuffer?new Uint8Array(body):body instanceof Uint8Array?body:new TextEncoder().encode(body||"");const digest=await crypto.subtle.digest("SHA-256",bytes);const hash=Array.from(new Uint8Array(digest),b=>b.toString(16).padStart(2,"0")).join("");const url=new URL(path,location.href).href;const event=await window.nostr.signEvent({kind:27235,created_at:Math.floor(Date.now()/1000),tags:[["u",url],["method",method],["payload",hash]],content:""});const request={method,signal:options.signal,headers:{authorization:"Nostr "+btoa(JSON.stringify(event)),"content-type":options.contentType||"application/octet-stream"}};if(method!=="GET"&&method!=="HEAD")request.body=bytes;const response=await fetch(path,request);if(!response.ok)throw Error(await response.text());return response};window.tinySignedFetch=signedFetch;754-const signIn=async()=>{say("Signing in…");await signedSession("/session");say("Signed in.");location.assign(localPath("/"))};755-const login=document.getElementById("session-login");if(login)login.addEventListener("click",async()=>{try{await signIn()}catch(err){say("Sign-in error: "+err.message)}});756-const logout=document.getElementById("session-logout");if(logout)logout.addEventListener("click",async()=>{try{say("Signing out…");const response=await fetch(localPath("/session/logout"),{method:"POST",credentials:"same-origin"});if(!response.ok)throw Error(await response.text());sessionStorage.removeItem(storageURI);sessionStorage.removeItem(storageKey);sessionStorage.removeItem(storagePubkey);sessionStorage.removeItem(storageRemote);sessionStorage.removeItem("tiny.bunker");say("Signed out.");location.reload()}catch(err){say("Sign-out error: "+err.message)}});757-const nc=document.getElementById("nostrconnect");758-const setSigner=(signer)=>{window.tinySigner=signer;window.nostr={signEvent:e=>window.tinySigner.signEvent(e)};if(document.getElementById("signer-status"))say(document.getElementById("session-logout")?"Signer connected.":"Signer connected. Choose Sign in with connected signer to continue.")};759-if(nc)nc.addEventListener("click",async ev=>{ev.preventDefault();const key=window.NostrSigner.generateSecretKey();const uri=window.NostrSigner.createNostrConnectURI({clientPubkey:window.NostrSigner.getPublicKey(key),relays:[location.origin.replace(/^http/,"ws")+root],secret:random(),name:"tiny",url:location.origin,perms:["sign_event:27235"]});const open=document.getElementById("nostrconnect-open");if(open){open.href=uri;open.hidden=false}if(qr){qr.src=root+"/qr.svg?text="+encodeURIComponent(uri);qr.hidden=false}navigator.clipboard?.writeText(uri).catch(()=>{});say("Scan the QR code or open the link in your signer.");window.tinyConnect={key,uri};try{const signer=await window.NostrSigner.BunkerSigner.fromURI(key,uri);setSigner(signer);sessionStorage.setItem(storageURI,uri);sessionStorage.setItem(storageKey,window.NostrSigner.bytesToHex(key));sessionStorage.setItem(storagePubkey,await signer.getPublicKey());sessionStorage.setItem(storageRemote,JSON.stringify(signer.bp));await signIn()}catch(err){say("Nostr Connect error: "+err.message)}});760-const bunker=document.getElementById("bunker");761-if(bunker)bunker.addEventListener("submit",async ev=>{ev.preventDefault();const value=document.getElementById("bunker-url").value.trim();if(!value)return;try{const secret=window.NostrSigner.generateSecretKey();const bp=await window.NostrSigner.parseBunkerInput(value);const signer=window.NostrSigner.BunkerSigner.fromBunker(secret,bp);await signer.connect();const signerPubkey=await signer.getPublicKey();setSigner(signer);sessionStorage.setItem(storageURI,value);sessionStorage.setItem(storageKey,window.NostrSigner.bytesToHex(secret));sessionStorage.setItem(storagePubkey,signerPubkey);sessionStorage.setItem(storageRemote,JSON.stringify(signer.bp));await signIn()}catch(err){say("Remote signer error: "+err.message)}});const resume=async()=>{const uri=sessionStorage.getItem(storageURI),hex=sessionStorage.getItem(storageKey),expected=sessionStorage.getItem(storagePubkey),remote=sessionStorage.getItem(storageRemote);if(!uri||!hex)return;try{const key=window.NostrSigner.hexToBytes(hex);let signer;if(remote){signer=window.NostrSigner.BunkerSigner.fromBunker(key,JSON.parse(remote));await signer.connect();}else{const bp=await window.NostrSigner.parseBunkerInput(uri);signer=window.NostrSigner.BunkerSigner.fromBunker(key,bp);await signer.connect();}const pubkey=await signer.getPublicKey();if(expected&&pubkey!==expected)throw Error("Remote signer identity changed");setSigner(signer)}catch(err){sessionStorage.removeItem(storageURI);sessionStorage.removeItem(storageKey);sessionStorage.removeItem(storagePubkey);sessionStorage.removeItem(storageRemote);say("Remote signer resume error: "+err.message)}};resume();762-const dataUpload=(id,input,method,path)=>{const form=document.getElementById(id);if(!form)return;form.addEventListener("submit",async ev=>{ev.preventDefault();const file=document.getElementById(input).files[0],out=document.getElementById("data-status");if(!file){if(out)out.textContent="Choose a file first.";return}try{if(out)out.textContent="Signing…";const response=await signedFetch(path,method,await file.arrayBuffer());if(out)out.textContent="Done: "+await response.text()}catch(err){if(out)out.textContent="Error: "+err.message}})};dataUpload("import-file","import-input","PUT","/import");const blobUpload=document.getElementById("blob-upload");if(blobUpload)blobUpload.addEventListener("submit",async ev=>{ev.preventDefault();const file=document.getElementById("blob-upload-input").files[0],out=blobUpload.querySelector("output");if(!file)return;try{out.textContent="Uploading…";const response=await signedFetch("/upload","PUT",await file.arrayBuffer());out.textContent="Uploaded: "+await response.text()}catch(err){out.textContent="Error: "+err.message}});dataUpload("backup-preview","backup-preview-input","POST","/backups/preview");dataUpload("backup-restore","backup-restore-input","POST","/backups/restore");const downloadFile=(id,name,path)=>{const form=document.getElementById(id);if(!form)return;form.addEventListener("submit",async ev=>{ev.preventDefault();const value=document.getElementById(name).value.trim();if(!value)return;try{const r=await signedFetch(path+encodeURIComponent(value),"GET",new Uint8Array()),blob=await r.blob(),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download=value;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}catch(err){const out=document.getElementById("data-status");if(out)out.textContent="Error: "+err.message}})};downloadFile("backup-download","backup-download-name","/backups/");downloadFile("dump-download","dump-download-name","/dumps/");763-})();`668+ a.render(writer, request, data)669+}764670 765671 var supportedMethods = []string{766672 "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains",
M internal/webui/webui_test.go +8 -8
@@ -49,7 +49,7 @@ func TestPagesUsePlainHTMLAndExposeAllManagementTabs(t *testing.T) {
4949 request := httptest.NewRequest(http.MethodGet, path, nil)5050 recorder := httptest.NewRecorder()5151 app.ServeHTTP(recorder, request)52- if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "<html") || !strings.Contains(recorder.Body.String(), `class="layout"`) {52+ if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "<html") || !strings.Contains(recorder.Body.String(), `id="layout"`) {5353 t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())5454 }5555 if strings.Contains(recorder.Body.String(), "fonts.googleapis") || strings.Contains(recorder.Body.String(), "bind.ws") {
@@ -156,7 +156,7 @@ func TestDataPageGuidesGitStorageArguments(t *testing.T) {
156156 recorder := httptest.NewRecorder()157157 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/data", nil))158158 body := recorder.Body.String()159- if !strings.Contains(body, `data-method="gitstorage"`) || !strings.Contains(body, `placeholder="64 hex pubkey"`) || !strings.Contains(body, `placeholder="repository name"`) {159+ if !strings.Contains(body, `method="gitstorage"`) || !strings.Contains(body, `placeholder="64 hex pubkey"`) || !strings.Contains(body, `placeholder="repository name"`) {160160 t.Fatalf("gitstorage form is missing guided owner and identifier fields: %s", body)161161 }162162 }
@@ -173,10 +173,10 @@ func TestGuidedFormsExposeSourceConsoleControls(t *testing.T) {
173173 t.Fatal(err)174174 }175175 cases := map[string][]string{176- "/manage/people": {`data-compose="member"`, `name="member-role"`},177- "/manage/rules": {`data-compose="memberInvites"`, `data-method="setretention"`},178- "/manage/identity": {`data-method="changerelayicon"`, `data-compose="identity"`, `Banner URL`},179- "/manage/owner": {`data-compose="succession"`, `data-method="clearsuccession"`},176+ "/manage/people": {`compose="member"`, `name="member-role"`},177+ "/manage/rules": {`compose="memberInvites"`, `method="setretention"`},178+ "/manage/identity": {`method="changerelayicon"`, `compose="identity"`, `Banner URL`},179+ "/manage/owner": {`compose="succession"`, `method="clearsuccession"`},180180 "/manage/data": {`placeholder="64 hex pubkey"`, `placeholder="repository name"`},181181 }182182 for path, wants := range cases {
@@ -199,7 +199,7 @@ func TestGuidedFormsExposeSourceConsoleControls(t *testing.T) {
199199 }200200 recorder = httptest.NewRecorder()201201 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/owner", nil))202- if strings.Contains(recorder.Body.String(), `data-method="deleterelay"><label>Type relay name to confirm <input name="param" value=`) {202+ if strings.Contains(recorder.Body.String(), `method="deleterelay"><label>Type relay name to confirm <input name="param" value=`) {203203 t.Fatal("delete confirmation was prefilled")204204 }205205 }
@@ -335,7 +335,7 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
335335 recorder := httptest.NewRecorder()336336 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/connect", nil))337337 body := recorder.Body.String()338- for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/connect/fragment"`, `localPath(form.dataset.endpoint||"/manage/rpc")`, `signedSession("/r/alice/session")`, `replace(/^http/,"ws")+root`} {338+ for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/connect/fragment"`, `localPath(this.getAttribute("action")||"/manage/rpc")`, `signedSession("/r/alice/session")`, `replace(/^http/,"ws")+root`} {339339 if !strings.Contains(body, want) {340340 t.Errorf("tenant prefix missing %q", want)341341 }