| 1 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 2 | new file mode 100644 |
| 3 | index 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 | +})(); |
| 173 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 174 | new file mode 100644 |
| 175 | index 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}} |
| 231 | diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go |
| 232 | index 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 | } |
| 244 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 245 | new file mode 100644 |
| 246 | index 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 | +})(); |
| 551 | diff --git a/internal/webui/guided_forms.go b/internal/webui/guided_forms.go |
| 552 | deleted file mode 100644 |
| 553 | index 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&¶ms.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&¶ms.length===1&&Array.isArray(params[0]))params=params[0];` |
| 580 | - return strings.Replace(page, old, newCode, 1) |
| 581 | -} |
| 582 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 583 | new file mode 100644 |
| 584 | index 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 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}} |
| 823 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 824 | new file mode 100644 |
| 825 | index 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}} |
| 921 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 922 | new file mode 100644 |
| 923 | index 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}} |
| 1096 | diff --git a/internal/webui/repo.html b/internal/webui/repo.html |
| 1097 | index 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")}}&repo={{urlquery (.Query.Get "repo")}}&ref={{urlquery (.Query.Get "ref")}}&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")}}&repo={{urlquery (.Query.Get "repo")}}&ref={{urlquery (.Query.Get "ref")}}&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}} |
| 1207 | diff --git a/internal/webui/repo_navigation.go b/internal/webui/repo_navigation.go |
| 1208 | index 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 { |
| 1228 | diff --git a/internal/webui/signin_test.go b/internal/webui/signin_test.go |
| 1229 | index 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 | } |
| 1241 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 1242 | new file mode 100644 |
| 1243 | index 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 | +} |
| 1352 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 1353 | index 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&¶ms.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 | +} |
| 1559 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 1560 | index 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", |
| 1810 | diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go |
| 1811 | index 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 | } |