tinyrelaycommit

Commit 013d9eeae583

browse files at this commit | history

1diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js
2new file mode 100644
3index 0000000..e1ac15f
4--- /dev/null
5+++ b/internal/webui/bridge.js
6@@ -0,0 +1,166 @@
7+// Signer bridge: session sign-in and sign-out, NIP-07 and NIP-46 signers,
8+// Nostr Connect, and the NIP-98 signed fetch that every management call uses.
9+// The browser never receives a relay private key; it only asks the signer.
10+(() => {
11+ "use strict";
12+
13+ const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];
14+ const localPath = path => path && path.startsWith("/") ? (path === root || path.startsWith(root + "/") ? path : root + path) : path;
15+ const storagePrefix = "tiny.bunker" + (root || "/");
16+ const storage = {
17+ uri: storagePrefix + ".uri",
18+ key: storagePrefix + ".sk",
19+ pubkey: storagePrefix + ".identity",
20+ remote: storagePrefix + ".remote"
21+ };
22+ const status = document.getElementById("signer-status") || document.getElementById("session-status");
23+ const say = text => { if (status) status.textContent = text; };
24+ const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
25+ const sha256hex = async bytes => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
26+ const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };
27+ const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");
28+
29+ // authorization builds a NIP-98 header for one request body. The body is
30+ // hashed exactly once and the same bytes must be sent.
31+ async function authorization(url, method, bytes) {
32+ if (!window.nostr?.signEvent) throw Error("Connect a Nostr signer first.");
33+ const event = await window.nostr.signEvent({
34+ kind: 27235,
35+ created_at: Math.floor(Date.now() / 1000),
36+ tags: [["u", url], ["method", method], ["payload", await sha256hex(bytes)]],
37+ content: ""
38+ });
39+ return "Nostr " + btoa(JSON.stringify(event));
40+ }
41+
42+ async function signedFetch(path, method, body, options = {}) {
43+ path = localPath(path);
44+ const bytes = toBytes(body);
45+ const url = new URL(path, location.href).href;
46+ const request = {
47+ method,
48+ signal: options.signal,
49+ headers: {authorization: await authorization(url, method, bytes), "content-type": options.contentType || "application/octet-stream"}
50+ };
51+ if (method !== "GET" && method !== "HEAD") request.body = bytes;
52+ const response = await fetch(path, request);
53+ if (!response.ok) throw Error(await response.text());
54+ return response;
55+ }
56+
57+ const signedSession = path => signedFetch(path, "POST", "");
58+ window.tiny = {root, localPath, sha256hex, authorization, signedFetch};
59+ window.tinySignedFetch = signedFetch;
60+
61+ const signIn = async () => {
62+ say("Signing in…");
63+ await signedSession("/session");
64+ say("Signed in.");
65+ location.assign(localPath("/"));
66+ };
67+
68+ const forgetRemote = () => {
69+ Object.values(storage).forEach(name => sessionStorage.removeItem(name));
70+ sessionStorage.removeItem("tiny.bunker");
71+ };
72+
73+ const rememberRemote = async (uri, key, signer) => {
74+ sessionStorage.setItem(storage.uri, uri);
75+ sessionStorage.setItem(storage.key, window.NostrSigner.bytesToHex(key));
76+ sessionStorage.setItem(storage.pubkey, await signer.getPublicKey());
77+ sessionStorage.setItem(storage.remote, JSON.stringify(signer.bp));
78+ };
79+
80+ const setSigner = signer => {
81+ window.tinySigner = signer;
82+ window.nostr = {signEvent: event => window.tinySigner.signEvent(event)};
83+ if (document.getElementById("signer-status")) {
84+ say(document.getElementById("session-logout") ? "Signer connected." : "Signer connected. Choose Sign in with connected signer to continue.");
85+ }
86+ };
87+
88+ document.getElementById("session-login")?.addEventListener("click", async () => {
89+ try { await signIn(); } catch (err) { say("Sign-in error: " + err.message); }
90+ });
91+
92+ document.getElementById("session-logout")?.addEventListener("click", async () => {
93+ try {
94+ say("Signing out…");
95+ const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});
96+ if (!response.ok) throw Error(await response.text());
97+ forgetRemote();
98+ say("Signed out.");
99+ location.reload();
100+ } catch (err) {
101+ say("Sign-out error: " + err.message);
102+ }
103+ });
104+
105+ // Nostr Connect: show a QR code and wait for a phone signer such as Amber.
106+ document.getElementById("nostrconnect")?.addEventListener("click", async ev => {
107+ ev.preventDefault();
108+ const key = window.NostrSigner.generateSecretKey();
109+ const uri = window.NostrSigner.createNostrConnectURI({
110+ clientPubkey: window.NostrSigner.getPublicKey(key),
111+ relays: [location.origin.replace(/^http/,"ws")+root],
112+ secret: randomHex(),
113+ name: "tiny",
114+ url: location.origin,
115+ perms: ["sign_event:27235"]
116+ });
117+ const open = document.getElementById("nostrconnect-open");
118+ if (open) { open.href = uri; open.hidden = false; }
119+ const qr = document.getElementById("signer-qr");
120+ if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; }
121+ navigator.clipboard?.writeText(uri).catch(() => {});
122+ say("Scan the QR code or open the link in your signer.");
123+ window.tinyConnect = {key, uri};
124+ try {
125+ const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri);
126+ setSigner(signer);
127+ await rememberRemote(uri, key, signer);
128+ await signIn();
129+ } catch (err) {
130+ say("Nostr Connect error: " + err.message);
131+ }
132+ });
133+
134+ // Bunker URL: connect to a remote signer the user pastes in.
135+ document.getElementById("bunker")?.addEventListener("submit", async ev => {
136+ ev.preventDefault();
137+ const value = document.getElementById("bunker-url").value.trim();
138+ if (!value) return;
139+ try {
140+ const secret = window.NostrSigner.generateSecretKey();
141+ const pointer = await window.NostrSigner.parseBunkerInput(value);
142+ const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer);
143+ await signer.connect();
144+ setSigner(signer);
145+ await rememberRemote(value, secret, signer);
146+ await signIn();
147+ } catch (err) {
148+ say("Remote signer error: " + err.message);
149+ }
150+ });
151+
152+ // Resume a remote signer stored for this tab so page loads keep working.
153+ (async () => {
154+ const uri = sessionStorage.getItem(storage.uri);
155+ const keyHex = sessionStorage.getItem(storage.key);
156+ const expected = sessionStorage.getItem(storage.pubkey);
157+ const remote = sessionStorage.getItem(storage.remote);
158+ if (!uri || !keyHex) return;
159+ try {
160+ const key = window.NostrSigner.hexToBytes(keyHex);
161+ const pointer = remote ? JSON.parse(remote) : await window.NostrSigner.parseBunkerInput(uri);
162+ const signer = window.NostrSigner.BunkerSigner.fromBunker(key, pointer);
163+ await signer.connect();
164+ const pubkey = await signer.getPublicKey();
165+ if (expected && pubkey !== expected) throw Error("Remote signer identity changed");
166+ setSigner(signer);
167+ } catch (err) {
168+ forgetRemote();
169+ say("Remote signer resume error: " + err.message);
170+ }
171+ })();
172+})();
173diff --git a/internal/webui/browse.html b/internal/webui/browse.html
174new file mode 100644
175index 0000000..dc33e52
176--- /dev/null
177+++ b/internal/webui/browse.html
178@@ -0,0 +1,52 @@
179+{{define "repos"}}<main>
180+<h2>Repositories</h2>
181+<p><a href="/repos">Refresh</a> | <a href="/manage/connect">Connect a repository relay</a></p>
182+{{if .Error}}{{template "alert" .Error}}
183+{{else if .Feed}}<scroll-box><table id="repo-list">
184+<thead><tr><th scope="col">Repository</th><th scope="col">Owner</th><th scope="col">Head</th><th scope="col">Private</th></tr></thead>
185+<tbody>
186+{{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>
187+{{end}}</tbody>
188+</table></scroll-box>
189+{{else}}<p>No repositories were reported.</p>
190+{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/repos" $.Query "cursor" .}}">Next page</a></p>
191+{{end}}</main>
192+{{end}}
193+
194+{{define "files"}}<main>
195+<h2>Files</h2>
196+<p><a href="/files">Refresh</a> | <a href="/manage/data">Upload or manage blobs</a></p>
197+{{if .Error}}{{template "alert" .Error}}
198+{{else if .Feed}}<scroll-box><table id="file-list">
199+<thead><tr><th scope="col">Hash</th><th scope="col">Type</th><th scope="col">Size</th><th scope="col">Uploaded</th></tr></thead>
200+<tbody>
201+{{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>
202+{{end}}</tbody>
203+</table></scroll-box>
204+{{else}}<p>No files were reported.</p>
205+{{end}}{{with (asMap .Event).next_cursor}}<p><a href="{{browsePageURL "/files" $.Query "cursor" .}}">Next page</a></p>
206+{{end}}</main>
207+{{end}}
208+
209+{{define "file"}}<main>
210+<h2>File</h2>
211+<p><a href="/files">All files</a></p>
212+{{if .Error}}{{template "alert" .Error}}
213+{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}
214+<p><a href="/files/raw?hash={{str (index $file "sha256")}}">Raw/download</a></p>
215+<scroll-box><table id="file-meta">
216+<tbody>
217+<tr><th scope="row">SHA-256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>
218+<tr><th scope="row">Type</th><td>{{str (index $file "type")}}</td></tr>
219+<tr><th scope="row">Size</th><td>{{str (index $file "size")}} bytes</td></tr>
220+{{with index $file "blossom_uri"}}<tr><th scope="row">Blossom URI</th><td><code>{{.}}</code></td></tr>
221+{{end}}</tbody>
222+</table></scroll-box>
223+{{if index $file "binary"}}<p>This is a binary file. Download it to view its contents.</p>
224+{{else}}{{sourceHTML .Event}}
225+{{end}}<details>
226+<summary>Manage file</summary>
227+<rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form>
228+</details>
229+{{end}}</main>
230+{{end}}
231diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
232index 9c1a7e1..fb71664 100644
233--- a/internal/webui/browse_test.go
234+++ b/internal/webui/browse_test.go
235@@ -79,7 +79,7 @@ func TestConnectPublishesGRASPAndDeliveryKinds(t *testing.T) {
236 recorder := httptest.NewRecorder()
237 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))
238 body := recorder.Body.String()
239- for _, marker := range []string{"10317", "10063", "tinySignedFetch", `name="relays"`, `map(x=>["g",x])`, `map(x=>["server",x])`, `tags:t,content:c`} {
240+ for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} {
241 if !strings.Contains(body, marker) {
242 t.Fatalf("connect page missing %q", marker)
243 }
244diff --git a/internal/webui/components.js b/internal/webui/components.js
245new file mode 100644
246index 0000000..aa82d9e
247--- /dev/null
248+++ b/internal/webui/components.js
249@@ -0,0 +1,301 @@
250+// Custom elements for the plain HTML pages. Every element renders in the
251+// light DOM with document APIs, takes plain lowercase attributes, and carries
252+// no styling of its own; page CSS targets the element name directly.
253+//
254+// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]>
255+// One signed NIP-86 management call. Children become the form body; the
256+// element appends an <output> status line and a <json-view> result.
257+// <signed-form action="…" method="PUT|POST|GET" [download]>
258+// A NIP-98 signed upload of one file, or a signed download by name.
259+// <publish-list kind="…" tag="…" [scheme="ws"]>
260+// Signs a replaceable list event from textarea lines and posts it.
261+// <json-view>
262+// Renders JSON as tables, lists and definition lists, keeping the raw
263+// text in a <details>. Server pages seed it with a <pre>; scripts set .value.
264+// Tables sit inside a <scroll-box> so wide rows scroll sideways.
265+// <nostr-key hex="…">
266+// Shortened key with the full hex as title; click copies it.
267+(() => {
268+ "use strict";
269+
270+ const tiny = window.tiny;
271+ const el = (name, text) => {
272+ const node = document.createElement(name);
273+ if (text !== undefined) node.textContent = text;
274+ return node;
275+ };
276+ const isHex64 = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
277+ const isURL = value => typeof value === "string" && /^(https?|wss?):\/\/\S+$/.test(value);
278+ const timeKey = /(^|_)(at|time|created|updated|expires|started|finished|since|until)$/;
279+ const isTimestamp = (key, value) => Number.isInteger(value) && value > 1e9 && value < 4e9 && timeKey.test(String(key || ""));
280+ const isPlain = value => value === null || typeof value !== "object";
281+
282+ // FormElement wraps light DOM children in a real <form> so labels, Enter
283+ // to submit, and required validation work natively, then adds a status line.
284+ class FormElement extends HTMLElement {
285+ connectedCallback() {
286+ if (this.form) return;
287+ const form = el("form");
288+ form.append(...this.childNodes);
289+ this.form = form;
290+ this.output = el("output");
291+ this.output.setAttribute("role", "status");
292+ this.append(form, this.output);
293+ form.addEventListener("submit", async event => {
294+ event.preventDefault();
295+ this.busy(true);
296+ try {
297+ await this.submit(form);
298+ } catch (err) {
299+ this.report("Error: " + err.message, true);
300+ } finally {
301+ this.busy(false);
302+ }
303+ });
304+ }
305+
306+ busy(on) {
307+ this.form.querySelectorAll("button").forEach(button => { button.disabled = on; });
308+ }
309+
310+ report(text, error) {
311+ this.output.textContent = text;
312+ if (error) this.output.dataset.error = "";
313+ else delete this.output.dataset.error;
314+ }
315+ }
316+
317+ const list = value => value.split(",").map(item => item.trim()).filter(Boolean);
318+ const composers = {
319+ 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}],
320+ memberInvites: f => [{memberInvites: {depth: Number(f.elements["member-invite-depth"].value), quota: Number(f.elements["member-invite-quota"].value)}}],
321+ succession: f => [{heir: f.elements["succession-heir"].value, afterDays: Number(f.elements["succession-days"].value)}],
322+ identity: f => [{
323+ contact: f.elements["identity-contact"].value,
324+ banner: f.elements["identity-banner"].value,
325+ postingPolicy: f.elements["identity-posting"].value,
326+ privacyPolicy: f.elements["identity-privacy"].value,
327+ tags: list(f.elements["identity-tags"].value),
328+ languageTags: list(f.elements["identity-languages"].value),
329+ relayCountries: list(f.elements["identity-countries"].value)
330+ }]
331+ };
332+
333+ class RpcForm extends FormElement {
334+ params(form) {
335+ const compose = composers[this.getAttribute("compose")];
336+ if (compose) return compose(form);
337+ let params = [...form.querySelectorAll("[name=param]")].map(input => {
338+ try { return JSON.parse(input.value); } catch { return input.value; }
339+ });
340+ if (this.hasAttribute("json-params") && params.length === 1 && Array.isArray(params[0])) params = params[0];
341+ return params;
342+ }
343+
344+ async submit(form) {
345+ const method = this.getAttribute("method") || form.elements.method?.value;
346+ if (!method) throw Error("Choose a method.");
347+ let params = this.params(form);
348+ if (method === "claiminvite") {
349+ const agree = form.querySelector("[name=termsAgreement]");
350+ if (agree && !agree.checked) { this.report("Agree to the relay terms before joining."); return; }
351+ const terms = this.getAttribute("terms");
352+ if (terms) params = [params[0], await tiny.sha256hex(new TextEncoder().encode(terms))];
353+ }
354+ const body = JSON.stringify({method, params});
355+ this.report("Signing…");
356+ const endpoint = tiny.localPath(this.getAttribute("action")||"/manage/rpc");
357+ const url = new URL(endpoint, location.href).href;
358+ const response = await fetch(endpoint, {
359+ method: "POST",
360+ headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))},
361+ body
362+ });
363+ const text = await response.text();
364+ let payload;
365+ try { payload = JSON.parse(text); } catch { payload = undefined; }
366+ if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")");
367+ const result = payload && "result" in payload ? payload.result : payload ?? text;
368+ this.report("Done.");
369+ this.show(result);
370+ }
371+
372+ show(result) {
373+ if (!this.result) {
374+ this.result = el("json-view");
375+ this.append(this.result);
376+ }
377+ this.result.value = result;
378+ }
379+ }
380+
381+ class SignedForm extends FormElement {
382+ async submit(form) {
383+ const action = this.getAttribute("action");
384+ const method = (this.getAttribute("method") || "POST").toUpperCase();
385+ if (this.hasAttribute("download")) {
386+ const name = form.elements.name.value.trim();
387+ if (!name) return;
388+ this.report("Signing…");
389+ const response = await tiny.signedFetch(action + encodeURIComponent(name), "GET", new Uint8Array());
390+ const href = URL.createObjectURL(await response.blob());
391+ const anchor = el("a");
392+ anchor.href = href;
393+ anchor.download = name;
394+ anchor.click();
395+ setTimeout(() => URL.revokeObjectURL(href), 1000);
396+ this.report("Downloaded " + name + ".");
397+ return;
398+ }
399+ const file = form.querySelector("input[type=file]")?.files[0];
400+ if (!file) { this.report("Choose a file first."); return; }
401+ this.report("Signing…");
402+ const response = await tiny.signedFetch(action, method, await file.arrayBuffer());
403+ this.report("Done: " + await response.text());
404+ }
405+ }
406+
407+ class PublishList extends FormElement {
408+ async submit(form) {
409+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
410+ const tag = this.getAttribute("tag");
411+ const scheme = this.getAttribute("scheme");
412+ const lines = form.elements.lines.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
413+ .filter(line => scheme !== "ws" || /^wss?:\/\//i.test(line));
414+ const event = await window.nostr.signEvent({
415+ kind: Number(this.getAttribute("kind")),
416+ created_at: Math.floor(Date.now() / 1000),
417+ tags: lines.map(line => [tag, line]),
418+ content: ""
419+ });
420+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event));
421+ let result = null;
422+ try { result = await response.clone().json(); } catch {}
423+ if (result?.error || result?.accepted === false) throw Error(result?.error || result?.message || "The relay rejected the event.");
424+ this.report("Published.");
425+ }
426+ }
427+
428+ class NostrKey extends HTMLElement {
429+ static get observedAttributes() { return ["hex"]; }
430+
431+ connectedCallback() {
432+ this.update();
433+ if (this.bound) return;
434+ this.bound = true;
435+ this.addEventListener("click", () => {
436+ navigator.clipboard?.writeText(this.getAttribute("hex") || "").then(() => {
437+ const title = this.title;
438+ this.title = "Copied";
439+ setTimeout(() => { this.title = title; }, 1200);
440+ }).catch(() => {});
441+ });
442+ }
443+
444+ attributeChangedCallback() { this.update(); }
445+
446+ update() {
447+ const hex = this.getAttribute("hex") || "";
448+ this.title = hex;
449+ if (!this.textContent.trim()) this.textContent = hex.slice(0, 12);
450+ }
451+ }
452+
453+ // JsonView renders any JSON value. Arrays of objects become tables, objects
454+ // become definition lists, scalar arrays become lists, and deep nesting
455+ // falls back to compact JSON so a response never explodes the page.
456+ class JsonView extends HTMLElement {
457+ connectedCallback() {
458+ if (this.rendered) return;
459+ const seed = this.querySelector(":scope > pre");
460+ if (!seed) return;
461+ try { this.render(JSON.parse(seed.textContent), seed); } catch { /* leave the raw text */ }
462+ }
463+
464+ set value(value) { this.render(value); }
465+
466+ render(value, seed) {
467+ this.rendered = true;
468+ const raw = seed || el("pre");
469+ raw.textContent = JSON.stringify(value, null, 2);
470+ const details = el("details");
471+ details.append(el("summary", "JSON"), raw);
472+ this.replaceChildren(this.node(value, 0), details);
473+ }
474+
475+ node(value, depth, key) {
476+ if (isPlain(value)) return this.scalar(value, key);
477+ if (depth >= 3) return el("code", JSON.stringify(value));
478+ if (Array.isArray(value)) {
479+ if (!value.length) return el("code", "[]");
480+ if (value.every(item => item && typeof item === "object" && !Array.isArray(item))) return this.table(value, depth);
481+ const items = el("ul");
482+ value.forEach(item => { const li = el("li"); li.append(this.node(item, depth + 1)); items.append(li); });
483+ return items;
484+ }
485+ const keys = Object.keys(value);
486+ if (!keys.length) return el("code", "{}");
487+ const dl = el("dl");
488+ keys.forEach(name => {
489+ const dd = el("dd");
490+ dd.append(this.node(value[name], depth + 1, name));
491+ dl.append(el("dt", name), dd);
492+ });
493+ return dl;
494+ }
495+
496+ table(rows, depth) {
497+ const columns = [];
498+ rows.forEach(row => Object.keys(row).forEach(name => { if (!columns.includes(name)) columns.push(name); }));
499+ const table = el("table");
500+ const head = el("tr");
501+ columns.forEach(name => { const th = el("th", name); th.setAttribute("scope", "col"); head.append(th); });
502+ const thead = el("thead");
503+ thead.append(head);
504+ const tbody = el("tbody");
505+ rows.forEach(row => {
506+ const tr = el("tr");
507+ columns.forEach(name => {
508+ const td = el("td");
509+ if (name in row) td.append(isPlain(row[name]) ? this.scalar(row[name], name) : el("code", JSON.stringify(row[name])));
510+ tr.append(td);
511+ });
512+ tbody.append(tr);
513+ });
514+ table.append(thead, tbody);
515+ const box = el("scroll-box");
516+ box.append(table);
517+ return box;
518+ }
519+
520+ scalar(value, key) {
521+ if (value === null || value === undefined) return el("code", "null");
522+ if (typeof value === "boolean") return el("code", String(value));
523+ if (typeof value === "number") {
524+ if (!isTimestamp(key, value)) return el("code", String(value));
525+ const time = el("time", new Date(value * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"));
526+ time.dateTime = new Date(value * 1000).toISOString();
527+ return time;
528+ }
529+ if (isHex64(value)) {
530+ const node = el("nostr-key", value.slice(0, 12));
531+ node.setAttribute("hex", value);
532+ return node;
533+ }
534+ if (isURL(value)) {
535+ const link = el("a", value);
536+ link.href = value;
537+ link.rel = "noopener";
538+ return link;
539+ }
540+ if (value.length > 160 || value.includes("\n")) return el("pre", value);
541+ return document.createTextNode(value);
542+ }
543+ }
544+
545+ customElements.define("rpc-form", RpcForm);
546+ customElements.define("signed-form", SignedForm);
547+ customElements.define("publish-list", PublishList);
548+ customElements.define("nostr-key", NostrKey);
549+ customElements.define("json-view", JsonView);
550+})();
551diff --git a/internal/webui/guided_forms.go b/internal/webui/guided_forms.go
552deleted file mode 100644
553index 73f438d..0000000
554--- a/internal/webui/guided_forms.go
555+++ /dev/null
556@@ -1,25 +0,0 @@
557-package webui
558-
559-import "strings"
560-
561-// guidedPageTemplate adds source-console controls to the compact HTML page
562-// without duplicating the generated template or its layout rules.
563-func guidedPageTemplate() string {
564- page := pageTemplate
565- replacements := []struct{ old, new string }{
566- {`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>`},
567- {`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>`},
568- {`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>`},
569- {`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>`},
570- {`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>`},
571- {`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>`},
572- {`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>`},
573- {`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`},
574- }
575- for _, replacement := range replacements {
576- page = strings.Replace(page, replacement.old, replacement.new, 1)
577- }
578- 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];`
579- 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];`
580- return strings.Replace(page, old, newCode, 1)
581-}
582diff --git a/internal/webui/manage.html b/internal/webui/manage.html
583new file mode 100644
584index 0000000..3b99402
585--- /dev/null
586+++ b/internal/webui/manage.html
587@@ -0,0 +1,235 @@
588+{{define "people"}}<main>
589+<h2>People</h2>
590+{{template "manageNav" .}}
591+<h3>Members</h3>
592+<rpc-form method="listmembers"><button>List members</button></rpc-form>
593+<rpc-form method="setmember" compose="member">
594+<label>Pubkey <input name="member-pubkey" pattern="[0-9a-f]{64}" required></label>
595+<label>Name <input name="member-name"></label>
596+<label>Note <input name="member-note"></label>
597+<label>Role <select name="member-role"><option value="member">Member</option><option value="moderator">Moderator</option></select></label>
598+<button>Save member</button>
599+</rpc-form>
600+<rpc-form method="removemember"><label>Pubkey <input name="param" required></label><button>Remove member</button></rpc-form>
601+<rpc-form method="removesubtree"><label>Member pubkey <input name="param" required></label><button>Remove invite subtree</button></rpc-form>
602+<h3>Invites</h3>
603+<rpc-form method="createinvite">
604+<label>TTL seconds <input name="param" type="number" value="259200"></label>
605+<label>Maximum uses <input name="param" type="number" value="1"></label>
606+<label>Note <input name="param"></label>
607+<button>Create invite</button>
608+</rpc-form>
609+<rpc-form method="listinvites"><button>List invites</button></rpc-form>
610+<h3>Claims</h3>
611+<rpc-form method="listclaims"><button>List claims</button></rpc-form>
612+<rpc-form method="createclaim"><label>JSON claim <input name="param" value="{}"></label><button>Create claim</button></rpc-form>
613+<rpc-form method="deleteclaim"><label>Claim code <input name="param" required></label><button>Delete claim</button></rpc-form>
614+<h3>Lists</h3>
615+<rpc-form method="listallowedpubkeys"><button>Allowed pubkeys</button></rpc-form>
616+<rpc-form method="listbannedpubkeys"><button>Banned pubkeys</button></rpc-form>
617+</main>
618+{{end}}
619+
620+{{define "moderation"}}<main>
621+<h2>Moderation</h2>
622+{{template "manageNav" .}}
623+<h3>Reports</h3>
624+<rpc-form method="listreports"><button>List reports</button></rpc-form>
625+<rpc-form method="resolvereport">
626+<label>Report ID <input name="param" required></label>
627+<label>JSON resolution <input name="param" value="{}"></label>
628+<button>Resolve report</button>
629+</rpc-form>
630+<h3>Events</h3>
631+<rpc-form method="listrecentevents"><button>Recent events</button></rpc-form>
632+<rpc-form method="searchevents"><label>JSON filter <input name="param" value="{}"></label><button>Search events</button></rpc-form>
633+<rpc-form method="banevent"><label>Event ID <input name="param" required></label><button>Hide event</button></rpc-form>
634+<rpc-form method="allowevent"><label>Event ID <input name="param" required></label><button>Allow event</button></rpc-form>
635+<rpc-form method="deleteevent"><label>Event ID <input name="param" required></label><button>Delete event</button></rpc-form>
636+<h3>Pins</h3>
637+<rpc-form method="listpins"><button>List pins</button></rpc-form>
638+<rpc-form method="pinevent"><label>Event ID or address <input name="param" required></label><button>Pin</button></rpc-form>
639+<rpc-form method="unpinevent"><label>Event ID or address <input name="param" required></label><button>Unpin</button></rpc-form>
640+<h3>Pubkeys</h3>
641+<rpc-form method="banpubkey"><label>Pubkey <input name="param" required></label><button>Ban</button></rpc-form>
642+</main>
643+{{end}}
644+
645+{{define "rules"}}<main>
646+<h2>Rules</h2>
647+{{template "manageNav" .}}
648+<h3>Policy</h3>
649+<rpc-form method="getpolicy"><button>Load policy</button></rpc-form>
650+<rpc-form method="setpolicy" compose="memberInvites">
651+<label>Member invite depth <input name="member-invite-depth" type="number" min="0" max="100" value="0"></label>
652+<label>Live invites per member <input name="member-invite-quota" type="number" min="0" max="1000" value="0"></label>
653+<button>Save member invites</button>
654+</rpc-form>
655+<rpc-form method="setpolicy"><label>JSON policy patch <textarea name="param">{}</textarea></label><button>Apply policy</button></rpc-form>
656+<h3>Kinds</h3>
657+<rpc-form method="allowkind"><label>Kind <input name="param" type="number" required></label><button>Allow kind</button></rpc-form>
658+<rpc-form method="disallowkind"><label>Kind <input name="param" type="number" required></label><button>Block kind</button></rpc-form>
659+<h3>Retention</h3>
660+<rpc-form method="setretention">
661+<label>Kind or null <input name="param" value="null" required></label>
662+<label>Keep days <input name="param" type="number" min="0" required></label>
663+<button>Save retention</button>
664+</rpc-form>
665+</main>
666+{{end}}
667+
668+{{define "identity"}}<main>
669+<h2>Identity</h2>
670+{{template "manageNav" .}}
671+<h3>Profile</h3>
672+<rpc-form method="changerelayname"><label>Name <input name="param" value="{{.Policy.Name}}"></label><button>Save name</button></rpc-form>
673+<rpc-form method="changerelaydescription"><label>Description <textarea name="param">{{.Policy.Description}}</textarea></label><button>Save description</button></rpc-form>
674+<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>
675+<h3>Directory listing</h3>
676+<rpc-form method="setpolicy" compose="identity">
677+<label>Contact <input name="identity-contact" value="{{.Policy.Contact}}" type="email" placeholder="you@example.com"></label>
678+<label>Banner URL <input name="identity-banner" value="{{.Policy.Banner}}" type="url" placeholder="https://…/banner.png"></label>
679+<label>Posting policy URL <input name="identity-posting" value="{{.Policy.PostingPolicy}}" type="url" placeholder="https://…/posting"></label>
680+<label>Privacy policy URL <input name="identity-privacy" value="{{.Policy.PrivacyPolicy}}" type="url" placeholder="https://…/privacy"></label>
681+<label>Tags <input name="identity-tags" value="{{join .Policy.Tags ", "}}" placeholder="bitcoin, cats"></label>
682+<label>Languages <input name="identity-languages" value="{{join .Policy.LanguageTags ", "}}" placeholder="en, pt-BR"></label>
683+<label>Countries <input name="identity-countries" value="{{join .Policy.RelayCountries ", "}}" placeholder="US, DE"></label>
684+<button>Save directory identity</button>
685+</rpc-form>
686+<h3>Ownership</h3>
687+<rpc-form method="transferowner"><label>New owner pubkey <input name="param" required></label><button>Transfer ownership</button></rpc-form>
688+</main>
689+{{end}}
690+
691+{{define "connect"}}<main>
692+<h2>Connect</h2>
693+{{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p>
694+{{end}}{{template "manageNav" .}}
695+<h3>Connections</h3>
696+<p><button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button></p>
697+<scroll-box><table id="connections-preview" aria-live="polite"><tbody><tr><td>No preview loaded.</td></tr></tbody></table></scroll-box>
698+<rpc-form method="listconnections"><button>List connections</button></rpc-form>
699+<rpc-form method="setconnections"><label>JSON connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form>
700+<rpc-form method="listconnectiontemplates"><button>List templates</button></rpc-form>
701+<h3>Publish signed relay records</h3>
702+<p>These actions open the configured signer and publish owner-authored Nostr records.</p>
703+<publish-list kind="10317" tag="g" scheme="ws">
704+<label>GRASP relay URLs, one per line <textarea name="lines" placeholder="wss://relay.example&#10;wss://backup.example"></textarea></label>
705+<button>Publish GRASP list</button>
706+</publish-list>
707+<publish-list kind="10063" tag="server">
708+<label>Blossom server URLs, one per line <textarea name="lines"></textarea></label>
709+<button>Publish server list</button>
710+</publish-list>
711+<h3>Sign in</h3>
712+{{template "signerControls" .}}
713+</main>
714+{{end}}
715+
716+{{define "data"}}<main>
717+<h2>Data</h2>
718+{{template "manageNav" .}}
719+<h3>Files</h3>
720+<signed-form action="/upload" method="PUT"><label>Upload file <input type="file" name="file" required></label><button>Upload</button></signed-form>
721+<rpc-form method="listblobs"><button>List blobs</button></rpc-form>
722+<rpc-form method="deleteblob"><label>Blob hash <input name="param" required></label><button>Delete blob</button></rpc-form>
723+<rpc-form method="listsites"><button>List sites</button></rpc-form>
724+<h3>Storage</h3>
725+<rpc-form method="storagestats"><button>Storage stats</button></rpc-form>
726+<rpc-form method="gitstorage">
727+<label>Repository owner <input name="param" required pattern="[0-9a-fA-F]{64}" placeholder="64 hex pubkey"></label>
728+<label>Repository identifier <input name="param" required maxlength="256" placeholder="repository name"></label>
729+<button>Git storage status</button>
730+</rpc-form>
731+<h3>Dumps</h3>
732+<rpc-form method="listdumps"><button>List dumps</button></rpc-form>
733+<rpc-form method="dumpnow"><button>Write dump</button></rpc-form>
734+<signed-form action="/dumps/" method="GET" download><label>Dump name <input name="name" required></label><button>Download dump</button></signed-form>
735+<rpc-form method="deletedump"><label>Dump name <input name="param" required></label><button>Delete dump</button></rpc-form>
736+<h3>Backups</h3>
737+<rpc-form method="listbackups"><button>List backups</button></rpc-form>
738+<rpc-form method="backupnow"><button>Create backup</button></rpc-form>
739+<signed-form action="/backups/" method="GET" download><label>Backup name <input name="name" required></label><button>Download backup</button></signed-form>
740+<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>
741+<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>
742+<rpc-form method="deletebackup"><label>Backup name <input name="param" required></label><button>Delete backup</button></rpc-form>
743+<h3>Import</h3>
744+<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>
745+</main>
746+{{end}}
747+
748+{{define "sync"}}<main>
749+<h2>Sync and delivery</h2>
750+{{template "manageNav" .}}
751+<h3>Jobs</h3>
752+<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>
753+<scroll-box><table id="job-status"><tbody><tr><td>Job status is idle.</td></tr></tbody></table></scroll-box>
754+<rpc-form method="listjobs"><button>List jobs</button></rpc-form>
755+<rpc-form method="addjob"><label>JSON job <input name="param" value="{}"></label><button>Add job</button></rpc-form>
756+<rpc-form method="runjob"><label>Job ID <input name="param" required></label><button>Run job</button></rpc-form>
757+<rpc-form method="removejob"><label>Job ID <input name="param" required></label><button>Remove job</button></rpc-form>
758+<h3>Pull and delivery</h3>
759+<rpc-form method="pullfrom">
760+<label>Relay URL <input name="param" type="url" required></label>
761+<label>JSON filter <input name="param" value="{}"></label>
762+<button>Add pull source</button>
763+</rpc-form>
764+<rpc-form method="pullstatus"><button>Pull status</button></rpc-form>
765+<rpc-form method="deliverystatus"><button>Delivery status</button></rpc-form>
766+<rpc-form method="backfill"><label>JSON backfill plan <input name="param" value="{}"></label><button>Backfill</button></rpc-form>
767+</main>
768+{{end}}
769+
770+{{define "views"}}<main>
771+<h2>Views</h2>
772+{{template "manageNav" .}}
773+<rpc-form method="listviews"><button>List views</button></rpc-form>
774+<rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form>
775+</main>
776+{{end}}
777+
778+{{define "health"}}<main>
779+<h2>Health</h2>
780+{{template "manageNav" .}}
781+<rpc-form method="stats"><button>Relay stats</button></rpc-form>
782+<rpc-form method="listaudit"><label>Before sequence <input name="param" type="number" value="0"></label><button>Audit log</button></rpc-form>
783+<rpc-form method="supportedmethods"><button>Supported methods</button></rpc-form>
784+</main>
785+{{end}}
786+
787+{{define "status"}}<main>
788+<h2>Service status</h2>
789+{{template "manageNav" .}}
790+{{if .Error}}{{template "alert" .Error}}
791+{{else}}{{template "jsonView" .Event}}
792+<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>
793+{{end}}</main>
794+{{end}}
795+
796+{{define "owner"}}<main>
797+<h2>Owner operations</h2>
798+{{template "manageNav" .}}
799+<p>Destructive actions ask the signer. The browser never receives or submits a private key.</p>
800+<h3>Configuration</h3>
801+<rpc-form method="exportconfig"><button>Export configuration</button></rpc-form>
802+<rpc-form method="planconfig"><label>Config JSON <textarea name="param">{}</textarea></label><button>Preview configuration</button></rpc-form>
803+<rpc-form method="importconfig"><label>Config JSON <textarea name="param">{}</textarea></label><button>Apply configuration</button></rpc-form>
804+<rpc-form method="forkrelay"><label>Fork options JSON <textarea name="param">{}</textarea></label><button>Fork relay</button></rpc-form>
805+<h3>Succession</h3>
806+<rpc-form method="setsuccession" compose="succession">
807+<label>Heir pubkey <input name="succession-heir" pattern="[0-9a-f]{64}" required></label>
808+<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>
809+<button>Set heir</button>
810+</rpc-form>
811+<rpc-form method="clearsuccession"><button>Clear heir</button></rpc-form>
812+<h3>Delete</h3>
813+<rpc-form method="deleterelay"><label>Type relay name to confirm <input name="param" placeholder="{{.Slug}}" required></label><button>Delete relay</button></rpc-form>
814+<h3>Any management method</h3>
815+<p>Every retained NIP-86 method remains callable through the signed form bridge. Dedicated tabs supply guided fields for the common ones.</p>
816+<rpc-form json-params>
817+<label>Method <select name="method">{{range .Methods}}<option>{{.}}</option>{{end}}</select></label>
818+<label>JSON parameters <input name="param" value="[]"></label>
819+<button>Run method</button>
820+</rpc-form>
821+</main>
822+{{end}}
823diff --git a/internal/webui/page.html b/internal/webui/page.html
824new file mode 100644
825index 0000000..adbb52c
826--- /dev/null
827+++ b/internal/webui/page.html
828@@ -0,0 +1,92 @@
829+{{define "page"}}<!doctype html>
830+<html lang="en">
831+<head>
832+<meta charset="utf-8">
833+<meta name="viewport" content="width=device-width,initial-scale=1">
834+<title>{{.Title}}</title>
835+<style>{{stylesheet}}</style>
836+<script src="/fixi.js"></script>
837+<script src="/signer.js"></script>
838+<script src="/webmcp.js"></script>
839+</head>
840+<body>
841+<table id="layout" aria-label="tiny relay"><tr><td>
842+<header>
843+{{if .Private}}<h1>Private relay</h1>
844+<p>Sign in with a Nostr signer to continue.</p>
845+{{else}}<h1><a href="/">{{.Slug}}</a></h1>
846+<p>{{.Policy.Description}}</p>
847+<p><code>{{.URL}}</code> | identity {{template "nostrKey" .Identity}}</p>
848+<nav aria-label="Site">
849+<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> |
850+{{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}}
851+</nav>
852+<p id="session-status" role="status"></p>
853+{{end}}</header>
854+{{if .Private}}{{template "signin" .}}
855+{{else if eq .Tab "home"}}{{template "home" .}}
856+{{else if eq .Tab "inbox"}}{{template "inbox" .}}
857+{{else if eq .Tab "outbox"}}{{template "outbox" .}}
858+{{else if eq .Tab "search"}}{{template "search" .}}
859+{{else if eq .Tab "articles"}}{{template "articles" .}}
860+{{else if eq .Tab "private"}}{{template "private" .}}
861+{{else if eq .Tab "chat"}}{{template "chat" .}}
862+{{else if eq .Tab "media"}}{{template "media" .}}
863+{{else if eq .Tab "site"}}{{template "site" .}}
864+{{else if eq .Tab "marmot"}}{{template "marmot" .}}
865+{{else if eq .Tab "grasp"}}{{template "grasp" .}}
866+{{else if eq .Tab "terms"}}{{template "terms" .}}
867+{{else if eq .Tab "people"}}{{template "people" .}}
868+{{else if eq .Tab "moderation"}}{{template "moderation" .}}
869+{{else if eq .Tab "rules"}}{{template "rules" .}}
870+{{else if eq .Tab "identity"}}{{template "identity" .}}
871+{{else if eq .Tab "signin"}}{{template "signin" .}}
872+{{else if eq .Tab "connect"}}{{template "connect" .}}
873+{{else if eq .Tab "data"}}{{template "data" .}}
874+{{else if eq .Tab "sync"}}{{template "sync" .}}
875+{{else if eq .Tab "views"}}{{template "views" .}}
876+{{else if eq .Tab "health"}}{{template "health" .}}
877+{{else if eq .Tab "status"}}{{template "status" .}}
878+{{else if eq .Tab "owner"}}{{template "owner" .}}
879+{{else if eq .Tab "repos"}}{{template "repos" .}}
880+{{else if eq .Tab "repo"}}{{template "repo" .}}
881+{{else if eq .Tab "files"}}{{template "files" .}}
882+{{else if eq .Tab "file"}}{{template "file" .}}
883+{{else if eq .Tab "tools"}}{{template "tools" .}}
884+{{else if eq .Tab "event"}}{{template "event" .}}
885+{{else if eq .Tab "view"}}{{template "view" .}}
886+{{else if eq .Tab "invite"}}{{template "invite" .}}
887+{{else}}{{template "home" .}}
888+{{end}}
889+<footer><a href="/.well-known/nostr.json">NIP-05</a> | <a href="/terms">Terms</a></footer>
890+</td></tr></table>
891+<script>{{script "bridge.js"}}</script>
892+<script>{{script "components.js"}}</script>
893+</body>
894+</html>
895+{{end}}
896+
897+{{/* Shared partials. Each renders one reusable element so pages never repeat markup for the same kind of data. */}}
898+
899+{{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}}
900+
901+{{define "nostrKey"}}<nostr-key hex="{{.}}">{{shortID .}}</nostr-key>{{end}}
902+
903+{{define "jsonView"}}<json-view><pre>{{json .}}</pre></json-view>{{end}}
904+
905+{{define "nostrEvent"}}{{$e := asMap .}}<nostr-event id="event-{{str (index $e "id")}}">
906+<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>
907+<pre>{{str (index $e "content")}}</pre>
908+<details><summary>Event JSON</summary>{{template "jsonView" .}}</details>
909+</nostr-event>{{end}}
910+
911+{{define "eventList"}}{{if .Feed}}<ol id="events">
912+{{range .Feed}}<li>{{template "nostrEvent" .}}</li>
913+{{end}}</ol>
914+{{else}}<p>No events.</p>
915+{{end}}{{end}}
916+
917+{{define "manageNav"}}<nav id="manage-nav" aria-label="Manage">
918+<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>
919+</nav>
920+{{end}}
921diff --git a/internal/webui/public.html b/internal/webui/public.html
922new file mode 100644
923index 0000000..fa96ad5
924--- /dev/null
925+++ b/internal/webui/public.html
926@@ -0,0 +1,169 @@
927+{{define "home"}}<main>
928+<h2>About this relay</h2>
929+<p>Browse notes, repositories and files. Connect your Nostr signer to publish and manage your relay.</p>
930+<dl>
931+<dt>Reads</dt><dd>{{.Policy.Reads}}</dd>
932+<dt>Writes</dt><dd>{{.Policy.Writes}}</dd>
933+<dt>Search</dt><dd>{{.Policy.Features.Search}}</dd>
934+<dt>Files</dt><dd>{{.Policy.Features.Files}}</dd>
935+</dl>
936+<p>Relay URL: <code>{{.URL}}</code></p>
937+</main>
938+{{end}}
939+
940+{{define "signin"}}<main>
941+<h2>{{if .Actor}}Your account{{else}}Sign in{{end}}</h2>
942+{{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>
943+<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a></p>
944+{{else}}<p>Use a browser extension or connect Amber on your phone.</p>
945+{{end}}{{template "signerControls" .}}
946+</main>
947+{{end}}
948+
949+{{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>
950+<h3>Phone signer</h3>
951+<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>
952+<p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p>
953+<p><img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240"></p>
954+<p id="signer-status" role="status"></p>
955+<details>
956+<summary>Use a bunker URL</summary>
957+<form id="bunker">
958+<label>Bunker URL <input id="bunker-url" type="url" placeholder="bunker://…" required></label>
959+<button>Connect remote signer</button>
960+</form>
961+</details>
962+{{end}}
963+
964+{{define "inbox"}}<main>
965+<h2>Inbox</h2>
966+<p>Events addressed to this signed-in key, subject to the same private-event gates as websocket queries.</p>
967+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}
968+</main>
969+{{end}}
970+
971+{{define "outbox"}}<main>
972+<h2>Outbox</h2>
973+<p>Events authored by this signed-in key.</p>
974+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}
975+</main>
976+{{end}}
977+
978+{{define "search"}}<main>
979+<h2>Search</h2>
980+<form id="search" action="/search" method="get">
981+<label>Terms <input name="q" type="search" value="{{.Query.Get "q"}}" required></label>
982+<button>Search</button>
983+</form>
984+{{if .Error}}{{template "alert" .Error}}
985+{{else if .Feed}}<h3>Results</h3>
986+{{template "eventList" .}}
987+{{else if .Query.Get "q"}}<p>No matching events.</p>
988+{{end}}</main>
989+{{end}}
990+
991+{{define "articles"}}<main>
992+<h2>Articles</h2>
993+<p>Long-form NIP-23 events published on this relay.</p>
994+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}
995+<p><a href="/feed.xml">Atom/RSS feed</a> | <a href="/articles.json">JSON feed</a></p>
996+</main>
997+{{end}}
998+
999+{{define "event"}}<main>
1000+<h2>Event</h2>
1001+<p><a href="/feed.xml">Atom feed</a> | <a href="/articles.json">JSON feed</a></p>
1002+{{if .Feed}}{{template "eventList" .}}{{else}}{{template "nostrEvent" .Event}}{{end}}
1003+</main>
1004+{{end}}
1005+
1006+{{define "view"}}<main>
1007+<h2>View: {{.View}}</h2>
1008+{{template "jsonView" .Event}}
1009+</main>
1010+{{end}}
1011+
1012+{{define "invite"}}<main>
1013+<h2>Join this relay</h2>
1014+<p>This invite is claimed with one signature from your Nostr signer. The relay never receives a private key.</p>
1015+{{if .Policy.JoinTerms}}<h3>Before you join</h3>
1016+<pre>{{.Policy.JoinTerms}}</pre>
1017+{{end}}<rpc-form method="claiminvite" action="/api/invites/claim" terms="{{.Policy.JoinTerms}}">
1018+<input type="hidden" name="param" value="{{.Invite}}">
1019+{{if .Policy.JoinTerms}}<label><input type="checkbox" name="termsAgreement" required> I agree to these relay terms.</label>
1020+{{end}}<button>Join relay</button>
1021+</rpc-form>
1022+</main>
1023+{{end}}
1024+
1025+{{define "terms"}}<main>
1026+<h2>Terms</h2>
1027+<p>{{.Policy.JoinTerms}}</p>
1028+</main>
1029+{{end}}
1030+
1031+{{define "tools"}}<main>
1032+<h2>Browser tools</h2>
1033+<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>
1034+<p id="webmcp-status" role="status">Checking browser support...</p>
1035+<ul id="webmcp-tools"></ul>
1036+<p id="webmcp-operation" role="status"></p>
1037+<details>
1038+<summary>Enable WebMCP</summary>
1039+<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>
1040+</details>
1041+<script>(() => {
1042+ const update = () => {
1043+ const state = window.tinyWebMCP;
1044+ document.getElementById("webmcp-status").textContent = !state?.supported
1045+ ? "WebMCP is unavailable in this browser."
1046+ : state.errors.length ? "Some tools could not register: " + state.errors.join("; ") : state.registered.length + " tools available.";
1047+ document.getElementById("webmcp-tools").replaceChildren(...(state?.registered || []).map(name => {
1048+ const item = document.createElement("li");
1049+ item.textContent = name;
1050+ return item;
1051+ }));
1052+ document.getElementById("webmcp-operation").textContent = state?.lastOperation || "";
1053+ };
1054+ window.addEventListener("tiny:webmcp", update);
1055+ update();
1056+ window.tinyWebMCP?.ready.then(update);
1057+})();</script>
1058+</main>
1059+{{end}}
1060+
1061+{{define "private"}}<main>
1062+<h2>Private messages</h2>
1063+<p>Sign in with a Nostr client to view addressed private events.</p>
1064+</main>
1065+{{end}}
1066+
1067+{{define "chat"}}<main>
1068+<h2>Chat</h2>
1069+<p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p>
1070+</main>
1071+{{end}}
1072+
1073+{{define "media"}}<main>
1074+<h2>Media</h2>
1075+<p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p>
1076+</main>
1077+{{end}}
1078+
1079+{{define "site"}}<main>
1080+<h2>Sites</h2>
1081+<p>This relay serves signed site manifests and snapshots when enabled by policy.</p>
1082+</main>
1083+{{end}}
1084+
1085+{{define "marmot"}}<main>
1086+<h2>Marmot</h2>
1087+<p>Marmot group state is served when the feature is enabled.</p>
1088+</main>
1089+{{end}}
1090+
1091+{{define "grasp"}}<main>
1092+<h2>GRASP</h2>
1093+<p>Repository state and relay capabilities are served through the GRASP endpoints.</p>
1094+</main>
1095+{{end}}
1096diff --git a/internal/webui/repo.html b/internal/webui/repo.html
1097index 743dbe7..32e778b 100644
1098--- a/internal/webui/repo.html
1099+++ b/internal/webui/repo.html
1100@@ -1,52 +1,57 @@
1101-{{define "repo"}}
1102-<main class="repository">
1103+{{define "repo"}}<main>
1104 <p><a href="/repos">All repositories</a></p>
1105-{{if .Error}}<h2>{{.Query.Get "repo"}}</h2><p role="alert">{{.Error}}</p>
1106-{{else}}
1107-{{$repo := asMap .Event}}{{$view := .Query.Get "view"}}
1108-<section class="repo-head">
1109- <h2>{{index $repo "identifier"}}</h2>
1110- <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>
1111- <nav class="repo-tabs" aria-label="Repository views">
1112- <a href="{{repoURL .Query "tree" ""}}"{{if or (eq $view "tree") (eq $view "file")}} aria-current="page"{{end}}>Code</a>
1113- <a href="{{repoURL .Query "history" ""}}"{{if or (eq $view "history") (eq $view "commit")}} aria-current="page"{{end}}>History</a>
1114- <a href="{{repoURL .Query "activity" ""}}"{{if eq $view "activity"}} aria-current="page"{{end}}>Activity</a>
1115- </nav>
1116- <div class="repo-toolbar">
1117- <form method="get" action="/repo">
1118- <input type="hidden" name="owner" value="{{.Query.Get "owner"}}">
1119- <input type="hidden" name="repo" value="{{.Query.Get "repo"}}">
1120- <input type="hidden" name="path" value="{{.Query.Get "path"}}">
1121- <input type="hidden" name="view" value="{{if eq $view "commit"}}tree{{else}}{{$view}}{{end}}">
1122- <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>
1123- <button>Go</button>
1124- </form>
1125- <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>
1126- </div>
1127+{{if .Error}}<h2>{{.Query.Get "repo"}}</h2>
1128+{{template "alert" .Error}}
1129+{{else}}{{$repo := asMap .Event}}{{$view := .Query.Get "view"}}
1130+<section id="repo-head">
1131+<h2>{{str (index $repo "identifier")}}</h2>
1132+<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>
1133+<nav id="repo-tabs" aria-label="Repository views">
1134+<a href="{{repoURL .Query "tree" ""}}"{{if or (eq $view "tree") (eq $view "file")}} aria-current="page"{{end}}>Code</a>
1135+<a href="{{repoURL .Query "history" ""}}"{{if or (eq $view "history") (eq $view "commit")}} aria-current="page"{{end}}>History</a>
1136+<a href="{{repoURL .Query "activity" ""}}"{{if eq $view "activity"}} aria-current="page"{{end}}>Activity</a>
1137+</nav>
1138+<form id="repo-ref" method="get" action="/repo">
1139+<input type="hidden" name="owner" value="{{.Query.Get "owner"}}">
1140+<input type="hidden" name="repo" value="{{.Query.Get "repo"}}">
1141+<input type="hidden" name="path" value="{{.Query.Get "path"}}">
1142+<input type="hidden" name="view" value="{{if eq $view "commit"}}tree{{else}}{{$view}}{{end}}">
1143+<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>
1144+<button>Go</button>
1145+</form>
1146+<details id="repo-clone">
1147+<summary>Clone</summary>
1148+{{range index $repo "clone"}}<p><input aria-label="Clone URL" readonly value="{{.}}" onclick="this.select()"></p>
1149+{{else}}<p>No clone URL advertised.</p>
1150+{{end}}</details>
1151 </section>
1152-{{if or (eq $view "tree") (eq $view "file")}}
1153-<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>
1154-{{end}}
1155-{{if eq $view "file"}}
1156- <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>
1157- {{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}}
1158-{{else if eq $view "tree"}}
1159- <table class="repo-tree"><thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Object</th></tr></thead><tbody>
1160- {{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>
1161- {{else}}<tr><td colspan="3">No published files in this directory.</td></tr>{{end}}
1162- </tbody></table>
1163-{{else if eq $view "history"}}
1164- <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>
1165- {{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}}
1166- </tbody></table>
1167-{{else if eq $view "commit"}}
1168- <h3>Commit <code>{{shortID (index $repo "commit")}}</code></h3>
1169- <p><a href="{{repoURL .Query "tree" ""}}">Browse files at this commit</a></p>
1170- <div class="source-wrap">{{diffHTML .Event}}</div>
1171-{{else if eq $view "activity"}}
1172- {{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}}
1173-{{end}}
1174-{{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}}
1175-{{end}}
1176-</main>
1177+{{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>
1178+{{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>
1179+{{if index $repo "binary"}}<p>This is a binary file. Download it to view its contents.</p>
1180+{{else if index $repo "content"}}{{sourceHTML .Event}}
1181+{{else}}<p>This file is empty.</p>
1182+{{end}}{{else if eq $view "tree"}}<scroll-box><table id="repo-tree">
1183+<thead><tr><th scope="col">Name</th><th scope="col">Size</th><th scope="col">Object</th></tr></thead>
1184+<tbody>
1185+{{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>
1186+{{else}}<tr><td colspan="3">No published files in this directory.</td></tr>
1187+{{end}}</tbody>
1188+</table></scroll-box>
1189+{{else if eq $view "history"}}<scroll-box><table id="repo-history">
1190+<thead><tr><th scope="col">Commit</th><th scope="col">Message</th><th scope="col">Author and date</th></tr></thead>
1191+<tbody>
1192+{{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>
1193+{{else}}<tr><td colspan="3">No commits.</td></tr>
1194+{{end}}</tbody>
1195+</table></scroll-box>
1196+{{else if eq $view "commit"}}<h3>Commit <code>{{shortID (index $repo "commit")}}</code></h3>
1197+<p><a href="{{repoURL .Query "tree" ""}}">Browse files at this commit</a></p>
1198+{{diffHTML .Event}}
1199+{{else if eq $view "activity"}}{{with index $repo "events"}}<ol id="events">
1200+{{range .}}<li>{{template "nostrEvent" .}}</li>
1201+{{end}}</ol>
1202+{{else}}<p>No repository activity.</p>
1203+{{end}}{{end}}
1204+{{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>
1205+{{end}}{{end}}</main>
1206 {{end}}
1207diff --git a/internal/webui/repo_navigation.go b/internal/webui/repo_navigation.go
1208index 7507eca..d294b24 100644
1209--- a/internal/webui/repo_navigation.go
1210+++ b/internal/webui/repo_navigation.go
1211@@ -1,16 +1,12 @@
1212 package webui
1213
1214 import (
1215- _ "embed"
1216 "fmt"
1217 "net/url"
1218 "strings"
1219 "time"
1220 )
1221
1222-//go:embed repo.html
1223-var repoTemplate string
1224-
1225 type repoCrumb struct{ Label, URL string }
1226
1227 func repoURL(query url.Values, view, path string) string {
1228diff --git a/internal/webui/signin_test.go b/internal/webui/signin_test.go
1229index 9ff734b..204e115 100644
1230--- a/internal/webui/signin_test.go
1231+++ b/internal/webui/signin_test.go
1232@@ -23,7 +23,7 @@ func TestSignInNavigationAndAccount(t *testing.T) {
1233 if actor != "" && (!strings.Contains(body, `title="`+actor+`"`) || strings.Count(body, `id="session-logout"`) != 1) {
1234 t.Fatalf("%s missing account or unique sign-out control", path)
1235 }
1236- if path == "/signin" && (!strings.Contains(body, `id="nostrconnect"`) || strings.Contains(body, `data-method="setconnections"`)) {
1237+ if path == "/signin" && (!strings.Contains(body, `id="nostrconnect"`) || strings.Contains(body, `method="setconnections"`)) {
1238 t.Fatal("sign-in page must show signer controls without connection settings")
1239 }
1240 }
1241diff --git a/internal/webui/style.css b/internal/webui/style.css
1242new file mode 100644
1243index 0000000..f7a99ac
1244--- /dev/null
1245+++ b/internal/webui/style.css
1246@@ -0,0 +1,105 @@
1247+html,body{margin:0;padding:0}
1248+body{font-family:system-ui,sans-serif;line-height:1.4;color:#202124}
1249+#layout{border-collapse:collapse;table-layout:fixed;width:960px;margin:0 auto}
1250+#layout>tbody>tr>td{padding:12px;vertical-align:top;overflow-wrap:anywhere}
1251+#layout>tbody>tr>td>header{border-bottom:1px solid #bbb}
1252+#layout>tbody>tr>td>footer{border-top:1px solid #bbb;margin-top:1rem;padding-top:.5rem}
1253+h1{font-size:1.6rem}
1254+h2{font-size:1.3rem}
1255+h3{font-size:1.05rem;margin:1.5rem 0 .5rem;border-bottom:1px solid #e2e2e2}
1256+input,textarea,button,select{max-width:100%;box-sizing:border-box;font:inherit}
1257+button{padding:.3rem .65rem}
1258+textarea{width:100%;min-height:5em;vertical-align:top}
1259+pre{white-space:pre-wrap;overflow-wrap:anywhere}
1260+code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
1261+[role=alert]{color:#a40000}
1262+[role=status]{color:#444}
1263+small{color:#666}
1264+nav a{white-space:nowrap}
1265+#manage-nav{margin:.5rem 0 1rem}
1266+
1267+/* Data tables inside the page body. Wide tables sit in a scroll-box and
1268+ scroll sideways instead of squeezing columns; cells never break mid-word. */
1269+scroll-box{display:block;overflow-x:auto;max-width:100%}
1270+main table{width:100%;border-collapse:collapse;margin:.5rem 0}
1271+main th,main td{padding:.35rem .5rem;border-bottom:1px solid #e2e2e2;text-align:left;vertical-align:top;overflow-wrap:normal}
1272+main th,main td code,main td nostr-key,main td time,main td a{white-space:nowrap}
1273+main th[scope=row]{width:9em}
1274+dl{margin:.5rem 0}
1275+dt{font-weight:600}
1276+dd{margin:0 0 .35rem 1rem}
1277+
1278+/* rpc-form: one signed management call. Labels wrap like inline text so short forms stay on one line. */
1279+rpc-form,signed-form,publish-list{display:block;margin:.6rem 0}
1280+rpc-form label,signed-form label,publish-list label{display:inline-block;margin:0 .6rem .35rem 0;vertical-align:top}
1281+rpc-form label:has(textarea),publish-list label:has(textarea){display:block}
1282+rpc-form output,signed-form output,publish-list output{display:block;margin:.3rem 0;color:#444}
1283+rpc-form output[data-error]{color:#a40000}
1284+
1285+/* json-view: structured rendering of any JSON payload, with the raw text kept behind a details toggle. */
1286+json-view{display:block}
1287+json-view>details>summary{cursor:pointer;color:#666}
1288+json-view>details>pre{margin:.3rem 0;padding:.5rem;border:1px solid #e2e2e2;background:#fafafa;font-size:13px}
1289+json-view table{font-size:14px}
1290+json-view td pre{margin:0;white-space:pre;max-width:40em;overflow:auto}
1291+json-view dl dl{margin:.2rem 0 .2rem 1rem}
1292+json-view ul{margin:.2rem 0;padding-left:1.2rem}
1293+json-view code{overflow-wrap:anywhere}
1294+
1295+/* nostr-event: a single event in feeds, activity, and detail pages. */
1296+#events{list-style:none;margin:0;padding:0}
1297+nostr-event{display:block;padding:.6rem 0;border-bottom:1px solid #e2e2e2}
1298+nostr-event>p{margin:0 0 .3rem;color:#555}
1299+nostr-event>pre{margin:0}
1300+nostr-event>details{margin-top:.3rem}
1301+nostr-event>details>summary{cursor:pointer;color:#666}
1302+nostr-key{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;cursor:copy}
1303+
1304+/* Repository browsing. */
1305+#repo-head{border:1px solid #d0d0d0;padding:12px;background:#fafafa}
1306+#repo-head h2{margin:0 0 6px}
1307+#repo-head p{margin:0 0 10px}
1308+#repo-tabs{display:block;border-bottom:1px solid #bbb;margin:1rem 0}
1309+#repo-tabs a{display:inline-block;padding:.45rem .7rem;text-decoration:none}
1310+#repo-tabs a[aria-current=page]{border:1px solid #bbb;border-bottom-color:white;margin-bottom:-1px;background:white;font-weight:600}
1311+#repo-ref{display:inline-block;margin:0 .5rem 0 0;vertical-align:middle}
1312+#repo-clone{display:inline-block;vertical-align:middle}
1313+#repo-clone input{width:40em;font:13px ui-monospace,monospace}
1314+#repo-breadcrumbs{margin:16px 0;font:14px ui-monospace,monospace}
1315+#file-heading{padding:8px 12px;margin:0;border:1px solid #ccc;border-bottom:0;background:#f5f5f5}
1316+#file-heading a{float:right}
1317+#repo-tree small{display:inline-block;width:5.5em;color:#777;font:12px ui-monospace,monospace}
1318+#repo-tree td:last-child,#repo-tree th:last-child{font-family:ui-monospace,monospace}
1319+#repo-pages a{margin-right:16px}
1320+
1321+/* source-view and diff-view wrap the tables emitted by the source renderer. */
1322+source-view,diff-view{display:block;overflow:auto;border:1px solid #ccc;background:#fafafa}
1323+source-view table,diff-view table{border-collapse:collapse;width:100%;margin:0;font:13px/20px ui-monospace,SFMono-Regular,Consolas,monospace}
1324+source-view td,diff-view td{padding:0 .75rem;border:0;vertical-align:top;white-space:pre;overflow-wrap:normal;word-break:normal}
1325+source-view td:first-child,diff-view td:first-child{color:#888;text-align:right;user-select:none;border-right:1px solid #ddd}
1326+source-view td:first-child a,diff-view td:first-child a{text-decoration:none;color:#777}
1327+source-view td:last-child,diff-view td:last-child{width:100%}
1328+source-view tr:target,diff-view tr:target{background:#fff1bc}
1329+source-view code,diff-view code{font:inherit}
1330+source-view b{font-weight:600}
1331+source-view i{font-style:normal;color:#777}
1332+source-view q{quotes:none;color:#146b2d}
1333+source-view q::before,source-view q::after{content:none}
1334+source-view var{font-style:normal;color:#7a3e00}
1335+source-view tfoot td,diff-view tfoot td{white-space:normal;color:#777;font-family:system-ui,sans-serif}
1336+diff-view ins,diff-view del{text-decoration:none}
1337+diff-view tr:has(ins){background:#e9f7e9}
1338+diff-view tr:has(del){background:#fbeaea}
1339+diff-view b{font-weight:600;color:#555}
1340+
1341+@media(max-width:979px){
1342+#layout{width:360px;max-width:100%;margin:0}
1343+#layout>tbody>tr>td{padding:8px}
1344+nav{line-height:2}
1345+#repo-tabs{overflow-x:auto}
1346+#repo-tabs a{white-space:nowrap}
1347+#repo-tree th:nth-child(3),#repo-tree td:nth-child(3){display:none}
1348+#repo-clone input{width:100%}
1349+source-view table,diff-view table{font-size:12px}
1350+source-view td,diff-view td{padding:0 .5rem}
1351+}
1352diff --git a/internal/webui/templates.go b/internal/webui/templates.go
1353index 785b056..9f9578f 100644
1354--- a/internal/webui/templates.go
1355+++ b/internal/webui/templates.go
1356@@ -1,43 +1,161 @@
1357 package webui
1358
1359-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>
1360-<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>
1361-{{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}}
1362-<footer><a href="/.well-known/nostr.json">NIP-05</a> | <a href="/terms">Terms</a></footer></td></tr></table>
1363-<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}}
1364-
1365-{{define "privateGate"}}{{template "signin" .}}{{end}}
1366-{{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}}
1367-{{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}}
1368-{{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}}
1369-{{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}}
1370-{{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}}
1371-{{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}}
1372-{{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}}
1373-{{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}}
1374-{{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}}
1375-{{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}}
1376-{{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}}
1377-{{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}}
1378-{{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}}
1379-{{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}}
1380-{{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}}
1381-{{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}}
1382-{{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}}
1383-{{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}}
1384-{{define "view"}}<main><h2>View: {{.View}}</h2><pre>{{json .Event}}</pre></main>{{end}}
1385-{{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}}
1386-{{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}}
1387-{{define "eventList"}}{{if .Feed}}<ol>{{range .Feed}}<li><pre>{{json .}}</pre></li>{{end}}</ol>{{else}}<p>No events.</p>{{end}}{{end}}
1388-{{define "private"}}<main><h2>Private messages</h2><p>Sign in with a Nostr client to view addressed private events.</p></main>{{end}}
1389-{{define "chat"}}<main><h2>Chat</h2><p>Group and direct conversations are available through NIP-17 and NIP-29 clients.</p></main>{{end}}
1390-{{define "media"}}<main><h2>Media</h2><p>Blossom uploads and NIP-96 metadata are available from compatible clients.</p></main>{{end}}
1391-{{define "site"}}<main><h2>Sites</h2><p>This relay serves signed site manifests and snapshots when enabled by policy.</p></main>{{end}}
1392-{{define "marmot"}}<main><h2>Marmot</h2><p>Marmot group state is served when the feature is enabled.</p></main>{{end}}
1393-{{define "grasp"}}<main><h2>GRASP</h2><p>Repository state and relay capabilities are served through the GRASP endpoints.</p></main>{{end}}
1394-{{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}}
1395-{{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}}
1396-{{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}}
1397-{{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}}
1398-{{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}}
1399-`
1400+import (
1401+ "embed"
1402+ "encoding/json"
1403+ "fmt"
1404+ "html/template"
1405+ "net/url"
1406+ "strings"
1407+ "time"
1408+)
1409+
1410+// templateFS holds every page template. page.html owns the shell and the
1411+// shared partials; the other files each define one group of tabs.
1412+//
1413+//go:embed page.html public.html manage.html browse.html repo.html
1414+var templateFS embed.FS
1415+
1416+// styleCSS is inlined into every page so the UI needs no extra request and
1417+// works unchanged under a tenant path prefix. It styles ids, elements and the
1418+// custom element names only; templates never carry class attributes.
1419+//
1420+//go:embed style.css
1421+var styleCSS string
1422+
1423+// bridgeJS is the signer bridge and componentsJS defines the custom
1424+// elements. Both are inlined at the end of the body in that order.
1425+//
1426+//go:embed bridge.js
1427+var bridgeJS string
1428+
1429+//go:embed components.js
1430+var componentsJS string
1431+
1432+func parseTemplates() (*template.Template, error) {
1433+ scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS}
1434+ funcs := template.FuncMap{
1435+ "stylesheet": func() template.CSS { return template.CSS(styleCSS) },
1436+ "script": func(name string) (template.JS, error) {
1437+ source, ok := scripts[name]
1438+ if !ok {
1439+ return "", fmt.Errorf("unknown script %q", name)
1440+ }
1441+ return template.JS(source), nil
1442+ },
1443+ "json": func(value any) string {
1444+ encoded, err := json.Marshal(value)
1445+ if err != nil {
1446+ return "null"
1447+ }
1448+ return string(encoded)
1449+ },
1450+ "join": strings.Join,
1451+ "urlquery": url.QueryEscape,
1452+ "asMap": valueMap,
1453+ "str": plainString,
1454+ "datetime": datetime,
1455+ "when": when,
1456+ "selectedView": func(query url.Values, view string) string {
1457+ if query.Get("view") == view || (query.Get("view") == "" && view == "tree") {
1458+ return " selected"
1459+ }
1460+ return ""
1461+ },
1462+ "repoCommitURL": repoCommitURL,
1463+ "hasNextOffset": hasNextOffset,
1464+ "repoURL": repoURL,
1465+ "repoBreadcrumbs": repoBreadcrumbs,
1466+ "browsePageURL": browsePageURL,
1467+ "shortID": shortID,
1468+ "repoDate": repoDate,
1469+ "sourceHTML": sourceHTML,
1470+ "diffHTML": diffHTML,
1471+ }
1472+ tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
1473+ if err != nil {
1474+ return nil, fmt.Errorf("parse web templates: %w", err)
1475+ }
1476+ return tmpl, nil
1477+}
1478+
1479+func valueMap(value any) map[string]any {
1480+ if result, ok := value.(map[string]any); ok {
1481+ return result
1482+ }
1483+ encoded, _ := json.Marshal(value)
1484+ var result map[string]any
1485+ _ = json.Unmarshal(encoded, &result)
1486+ return result
1487+}
1488+
1489+// plainString prints a decoded JSON value for display: nil becomes empty,
1490+// whole floats print without a fraction, and everything else uses fmt.
1491+func plainString(value any) string {
1492+ switch value := value.(type) {
1493+ case nil:
1494+ return ""
1495+ case string:
1496+ return value
1497+ case float64:
1498+ if value == float64(int64(value)) {
1499+ return fmt.Sprintf("%d", int64(value))
1500+ }
1501+ return fmt.Sprintf("%v", value)
1502+ default:
1503+ return fmt.Sprint(value)
1504+ }
1505+}
1506+
1507+// datetime renders a Unix timestamp as UTC text suitable for a time element.
1508+func datetime(value any) string {
1509+ seconds := unixSeconds(value)
1510+ if seconds <= 0 {
1511+ return ""
1512+ }
1513+ return time.Unix(seconds, 0).UTC().Format("2006-01-02T15:04:05Z")
1514+}
1515+
1516+// when renders a Unix timestamp as short UTC text for people to read.
1517+func when(value any) string {
1518+ seconds := unixSeconds(value)
1519+ if seconds <= 0 {
1520+ return ""
1521+ }
1522+ return time.Unix(seconds, 0).UTC().Format("2006-01-02 15:04 UTC")
1523+}
1524+
1525+func unixSeconds(value any) int64 {
1526+ switch value := value.(type) {
1527+ case float64:
1528+ return int64(value)
1529+ case int64:
1530+ return value
1531+ case int:
1532+ return int64(value)
1533+ }
1534+ return 0
1535+}
1536+
1537+// tableRows renders escaped table head and body markup for fragments and
1538+// streams that swap into an existing table element.
1539+func tableRows(headers []string, rows [][]string) string {
1540+ var out strings.Builder
1541+ out.WriteString(`<thead><tr>`)
1542+ for _, header := range headers {
1543+ out.WriteString(`<th scope="col">` + template.HTMLEscapeString(header) + `</th>`)
1544+ }
1545+ out.WriteString(`</tr></thead><tbody>`)
1546+ if len(rows) == 0 {
1547+ out.WriteString(`<tr><td colspan="` + fmt.Sprint(len(headers)) + `">No entries.</td></tr>`)
1548+ }
1549+ for _, row := range rows {
1550+ out.WriteString(`<tr>`)
1551+ for _, cell := range row {
1552+ out.WriteString(`<td>` + template.HTMLEscapeString(cell) + `</td>`)
1553+ }
1554+ out.WriteString(`</tr>`)
1555+ }
1556+ out.WriteString(`</tbody>`)
1557+ return out.String()
1558+}
1559diff --git a/internal/webui/webui.go b/internal/webui/webui.go
1560index 2c5977b..61be34d 100644
1561--- a/internal/webui/webui.go
1562+++ b/internal/webui/webui.go
1563@@ -82,54 +82,9 @@ func New(backend Backend, options Options) (*App, error) {
1564 if backend == nil {
1565 return nil, fmt.Errorf("webui backend is required")
1566 }
1567- page := guidedPageTemplate() + repoTemplate
1568- tmpl, err := template.New("webui").Funcs(template.FuncMap{
1569- "json": func(value any) string {
1570- encoded, encodeErr := json.Marshal(value)
1571- if encodeErr != nil {
1572- return "null"
1573- }
1574- return string(encoded)
1575- },
1576- "join": strings.Join,
1577- "urlquery": url.QueryEscape,
1578- "asMap": func(value any) map[string]any {
1579- if result, ok := value.(map[string]any); ok {
1580- return result
1581- }
1582- encoded, _ := json.Marshal(value)
1583- var result map[string]any
1584- _ = json.Unmarshal(encoded, &result)
1585- return result
1586- },
1587- "lineRows": func(value any) []sourceLine {
1588- m := valueMap(value)
1589- content, _ := m["content"].(string)
1590- parts := strings.Split(content, "\n")
1591- rows := make([]sourceLine, 0, len(parts))
1592- for number, text := range parts {
1593- rows = append(rows, sourceLine{Number: number + 1, Text: text})
1594- }
1595- return rows
1596- },
1597- "selectedView": func(query url.Values, view string) string {
1598- if query.Get("view") == view || (query.Get("view") == "" && view == "tree") {
1599- return " selected"
1600- }
1601- return ""
1602- },
1603- "repoCommitURL": repoCommitURL,
1604- "hasNextOffset": hasNextOffset,
1605- "repoURL": repoURL,
1606- "repoBreadcrumbs": repoBreadcrumbs,
1607- "browsePageURL": browsePageURL,
1608- "shortID": shortID,
1609- "repoDate": repoDate,
1610- "sourceHTML": sourceHTML,
1611- "diffHTML": diffHTML,
1612- }).Parse(page)
1613+ tmpl, err := parseTemplates()
1614 if err != nil {
1615- return nil, fmt.Errorf("parse web templates: %w", err)
1616+ return nil, err
1617 }
1618 qr := options.QR
1619 if qr == nil {
1620@@ -138,21 +93,6 @@ func New(backend Backend, options Options) (*App, error) {
1621 return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl}, nil
1622 }
1623
1624-type sourceLine struct {
1625- Number int
1626- Text string
1627-}
1628-
1629-func valueMap(value any) map[string]any {
1630- if result, ok := value.(map[string]any); ok {
1631- return result
1632- }
1633- encoded, _ := json.Marshal(value)
1634- var result map[string]any
1635- _ = json.Unmarshal(encoded, &result)
1636- return result
1637-}
1638-
1639 func defaultQR(text string) ([]byte, error) {
1640 code, err := qrcode.New(text, qrcode.Medium)
1641 if err != nil {
1642@@ -335,17 +275,12 @@ func jobsFragment(result any, queryErr error) string {
1643 if queryErr != nil {
1644 return `<tbody><tr><td role="status">` + template.HTMLEscapeString(queryErr.Error()) + `</td></tr></tbody>`
1645 }
1646- encoded, _ := json.Marshal(result)
1647- var rows []any
1648- _ = json.Unmarshal(encoded, &rows)
1649- var out strings.Builder
1650- out.WriteString(`<thead><tr><th>Job</th><th>Status</th><th>Updated</th></tr></thead><tbody>`)
1651- for _, row := range rows {
1652+ rows := make([][]string, 0)
1653+ for _, row := range browseRows(result) {
1654 values, _ := row.(map[string]any)
1655- 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>`)
1656+ rows = append(rows, []string{plainString(nestedValue(values, "spec", "id")), plainString(firstValue(values, "phase", "status")), plainString(firstValue(values, "finished", "started"))})
1657 }
1658- out.WriteString(`</tbody>`)
1659- return out.String()
1660+ return tableRows([]string{"Job", "Status", "Updated"}, rows)
1661 }
1662
1663 func nestedValue(values map[string]any, keys ...string) any {
1664@@ -455,18 +390,14 @@ func (a *App) connectFragment(writer http.ResponseWriter, request *http.Request)
1665 http.Error(writer, "connections unavailable", http.StatusNotFound)
1666 return
1667 }
1668- encoded, _ := json.Marshal(result)
1669- var rows []any
1670- _ = json.Unmarshal(encoded, &rows)
1671- var out strings.Builder
1672- out.WriteString(`<table id="connections-preview"><thead><tr><th>Label</th><th>Relay</th><th>Status</th></tr></thead><tbody>`)
1673- for _, row := range rows {
1674+ rows := make([][]string, 0)
1675+ for _, row := range browseRows(result) {
1676 values, _ := row.(map[string]any)
1677- 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>`)
1678+ rows = append(rows, []string{plainString(firstValue(values, "label", "title", "name", "template")), plainString(firstValue(values, "url", "href", "relay")), plainString(firstValue(values, "status", "visibility"))})
1679 }
1680- out.WriteString(`</tbody></table>`)
1681+ fragment := `<table id="connections-preview" aria-live="polite">` + tableRows([]string{"Label", "Relay", "Status"}, rows) + `</table>`
1682 writer.Header().Set("content-type", "text/html; charset=utf-8")
1683- _, _ = writer.Write([]byte(out.String()))
1684+ _, _ = writer.Write([]byte(fragment))
1685 }
1686
1687 func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {
1688@@ -491,7 +422,11 @@ func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {
1689 http.NotFound(writer, request)
1690 return
1691 }
1692- a.renderData(writer, request, PageData{Event: result, View: identifier, Tab: "event"})
1693+ data := PageData{Event: result, View: identifier, Tab: "event"}
1694+ if address {
1695+ data.Feed = browseRows(result)
1696+ }
1697+ a.render(writer, request, data)
1698 }
1699
1700 func (a *App) viewPage(writer http.ResponseWriter, request *http.Request) {
1701@@ -566,7 +501,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
1702 params = append(params, raw)
1703 result, err := a.backend.Query(request.Context(), method, params, actor)
1704 if err != nil {
1705- a.renderData(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})
1706+ a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})
1707 return
1708 }
1709 pageQuery := request.URL.Query()
1710@@ -574,7 +509,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
1711 pageQuery.Set("view", "tree")
1712 }
1713 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}
1714- a.renderData(writer, request, data)
1715+ a.render(writer, request, data)
1716 }
1717
1718 func browseTab(path string) string {
1719@@ -613,10 +548,12 @@ func browseRows(value any) []any {
1720
1721 func (a *App) invitePage(writer http.ResponseWriter, request *http.Request) {
1722 code := strings.TrimPrefix(request.URL.Path, "/invite/")
1723- a.renderData(writer, request, PageData{Invite: code, Tab: "invite"})
1724+ a.render(writer, request, PageData{Invite: code, Tab: "invite"})
1725 }
1726
1727-func (a *App) renderData(writer http.ResponseWriter, request *http.Request, data PageData) {
1728+// render fills the shared page data, applies private relay masking, and
1729+// writes the page. Every HTML response goes through here.
1730+func (a *App) render(writer http.ResponseWriter, request *http.Request, data PageData) {
1731 actor, _ := a.resolveActor(request)
1732 if data.Title == "" {
1733 data.Title = a.backend.Slug()
1734@@ -624,6 +561,9 @@ func (a *App) renderData(writer http.ResponseWriter, request *http.Request, data
1735 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)
1736 data.Owner = actor != "" && actor == data.Policy.Owner
1737 data.Methods = supportedMethods
1738+ if data.Query == nil {
1739+ data.Query = request.URL.Query()
1740+ }
1741 a.privatePageData(&data, request, actor)
1742 writer.Header().Set("Cache-Control", "private, no-store")
1743 writer.Header().Set("content-type", "text/html; charset=utf-8")
1744@@ -632,10 +572,7 @@ func (a *App) renderData(writer http.ResponseWriter, request *http.Request, data
1745 http.Error(writer, "render page: "+err.Error(), http.StatusInternalServerError)
1746 return
1747 }
1748- html := strings.Replace(rendered.String(), "</head>", "<script src=\"/fixi.js\"></script><script src=\"/signer.js\"></script><script src=\"/webmcp.js\"></script></head>", 1)
1749- html = strings.Replace(html, "</body>", "<script>"+signerBridgeJS+"</script></body>", 1)
1750- html = injectBase(html, data.Base)
1751- _, _ = writer.Write([]byte(html))
1752+ _, _ = writer.Write([]byte(injectBase(rendered.String(), data.Base)))
1753 }
1754
1755 func identityNpub(identity string) string {
1756@@ -716,11 +653,7 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
1757 if err != nil {
1758 actor = ""
1759 }
1760- 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)}
1761- data.Query = request.URL.Query()
1762- data.Owner = actor != "" && actor == data.Policy.Owner
1763- a.privatePageData(&data, request, actor)
1764- writer.Header().Set("Cache-Control", "private, no-store")
1765+ data := PageData{Tab: tab, Query: request.URL.Query()}
1766 if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" {
1767 feed, feedErr := a.publicFeed(request.Context(), tab, actor, request.URL.Query())
1768 if feedErr != nil {
1769@@ -730,37 +663,10 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
1770 }
1771 }
1772 if tab != "" {
1773- data.Title = tab + " | " + data.Slug
1774+ data.Title = tab + " | " + a.backend.Slug()
1775 }
1776- writer.Header().Set("content-type", "text/html; charset=utf-8")
1777- var rendered bytes.Buffer
1778- if err := a.tmpl.ExecuteTemplate(&rendered, "page", data); err != nil {
1779- http.Error(writer, "render page: "+err.Error(), http.StatusInternalServerError)
1780- return
1781- }
1782- html := strings.Replace(rendered.String(), "</head>", "<script src=\"/fixi.js\"></script><script src=\"/signer.js\"></script><script src=\"/webmcp.js\"></script></head>", 1)
1783- html = strings.Replace(html, "</body>", "<script>"+signerBridgeJS+"</script></body>", 1)
1784- html = injectBase(html, data.Base)
1785- _, _ = writer.Write([]byte(html))
1786-}
1787-
1788-const signerBridgeJS = `(()=>{
1789-const root=(location.pathname.match(/^\/r\/[^/]+/)||[""])[0],localPath=p=>p&&p.startsWith("/")?(p===root||p.startsWith(root+"/")?p:root+p):p;
1790-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";
1791-const say=(text)=>{if(status)status.textContent=text};
1792-const random=()=>{const b=new Uint8Array(32);crypto.getRandomValues(b);return [...b].map(x=>x.toString(16).padStart(2,"0")).join("")};
1793-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};
1794-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;
1795-const signIn=async()=>{say("Signing in…");await signedSession("/session");say("Signed in.");location.assign(localPath("/"))};
1796-const login=document.getElementById("session-login");if(login)login.addEventListener("click",async()=>{try{await signIn()}catch(err){say("Sign-in error: "+err.message)}});
1797-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)}});
1798-const nc=document.getElementById("nostrconnect");
1799-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.")};
1800-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)}});
1801-const bunker=document.getElementById("bunker");
1802-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();
1803-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/");
1804-})();`
1805+ a.render(writer, request, data)
1806+}
1807
1808 var supportedMethods = []string{
1809 "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",
1810diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
1811index d7b4f72..e25cfb3 100644
1812--- a/internal/webui/webui_test.go
1813+++ b/internal/webui/webui_test.go
1814@@ -49,7 +49,7 @@ func TestPagesUsePlainHTMLAndExposeAllManagementTabs(t *testing.T) {
1815 request := httptest.NewRequest(http.MethodGet, path, nil)
1816 recorder := httptest.NewRecorder()
1817 app.ServeHTTP(recorder, request)
1818- if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "<html") || !strings.Contains(recorder.Body.String(), `class="layout"`) {
1819+ if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "<html") || !strings.Contains(recorder.Body.String(), `id="layout"`) {
1820 t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())
1821 }
1822 if strings.Contains(recorder.Body.String(), "fonts.googleapis") || strings.Contains(recorder.Body.String(), "bind.ws") {
1823@@ -156,7 +156,7 @@ func TestDataPageGuidesGitStorageArguments(t *testing.T) {
1824 recorder := httptest.NewRecorder()
1825 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/data", nil))
1826 body := recorder.Body.String()
1827- if !strings.Contains(body, `data-method="gitstorage"`) || !strings.Contains(body, `placeholder="64 hex pubkey"`) || !strings.Contains(body, `placeholder="repository name"`) {
1828+ if !strings.Contains(body, `method="gitstorage"`) || !strings.Contains(body, `placeholder="64 hex pubkey"`) || !strings.Contains(body, `placeholder="repository name"`) {
1829 t.Fatalf("gitstorage form is missing guided owner and identifier fields: %s", body)
1830 }
1831 }
1832@@ -173,10 +173,10 @@ func TestGuidedFormsExposeSourceConsoleControls(t *testing.T) {
1833 t.Fatal(err)
1834 }
1835 cases := map[string][]string{
1836- "/manage/people": {`data-compose="member"`, `name="member-role"`},
1837- "/manage/rules": {`data-compose="memberInvites"`, `data-method="setretention"`},
1838- "/manage/identity": {`data-method="changerelayicon"`, `data-compose="identity"`, `Banner URL`},
1839- "/manage/owner": {`data-compose="succession"`, `data-method="clearsuccession"`},
1840+ "/manage/people": {`compose="member"`, `name="member-role"`},
1841+ "/manage/rules": {`compose="memberInvites"`, `method="setretention"`},
1842+ "/manage/identity": {`method="changerelayicon"`, `compose="identity"`, `Banner URL`},
1843+ "/manage/owner": {`compose="succession"`, `method="clearsuccession"`},
1844 "/manage/data": {`placeholder="64 hex pubkey"`, `placeholder="repository name"`},
1845 }
1846 for path, wants := range cases {
1847@@ -199,7 +199,7 @@ func TestGuidedFormsExposeSourceConsoleControls(t *testing.T) {
1848 }
1849 recorder = httptest.NewRecorder()
1850 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/owner", nil))
1851- if strings.Contains(recorder.Body.String(), `data-method="deleterelay"><label>Type relay name to confirm <input name="param" value=`) {
1852+ if strings.Contains(recorder.Body.String(), `method="deleterelay"><label>Type relay name to confirm <input name="param" value=`) {
1853 t.Fatal("delete confirmation was prefilled")
1854 }
1855 }
1856@@ -335,7 +335,7 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
1857 recorder := httptest.NewRecorder()
1858 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/connect", nil))
1859 body := recorder.Body.String()
1860- 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`} {
1861+ 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`} {
1862 if !strings.Contains(body, want) {
1863 t.Errorf("tenant prefix missing %q", want)
1864 }

About

ownerc01503e8035f
visibilitypublic
head013d9eeae583

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs