clone

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

offer relay list hints on the account page and show connections at home

Dami committed 5df2756
The account page lists the NIP-65 relay list, NIP-17 DM inbox, NIP-51
search relays and BUD-03 Blossom servers with check and add controls; a
relay-lists element reads the current list over the websocket, adds this
relay, signs with the connected signer and publishes to this relay and
the relays already listed. The home page shows the relay's configured
connections to signed-in owners and moderators.
commit
5df27560c504297b6e3122447c82686e28298194
parent
885c663bcd45f45941ff45a522c7cfe018dee4ec
author
Dami <dami.etoile@gmail.com>
6 files changed, +177 -0
Minternal/webui/components.js95++++++++++++++++++++++++++++++
Minternal/webui/public.html20++++++
Minternal/webui/shell_test.go40+++++++++++++
Minternal/webui/style.css2+
Minternal/webui/templates.go12++++
Minternal/webui/webui.go8+++
M internal/webui/components.js +95 -0
@@ -296,7 +296,102 @@
296296 }297297 }298298 299+ // RelayLists reads the signed-in key's relay lists from this relay, says300+ // whether this relay is on each, and republishes a list with this relay301+ // added. Lists are replaceable events, so the new one carries every302+ // existing entry plus this relay. Each publish goes to this relay and to303+ // the relays already on the list so other clients find the update.304+ class RelayLists extends HTMLElement {305+ connectedCallback() {306+ if (this.bound) return;307+ this.bound = true;308+ this.output = this.querySelector("output");309+ this.addEventListener("click", event => {310+ const button = event.target.closest("button[name]");311+ const row = button?.closest("tr[data-kind]");312+ if (!button || !row) return;313+ button.disabled = true;314+ const action = button.name === "add" ? this.add(row) : this.check(row);315+ action.catch(err => this.say(row, "error: " + err.message)).finally(() => { button.disabled = false; });316+ });317+ }318+319+ say(row, text) {320+ let note = row.querySelector("output");321+ if (!note) { note = el("output"); row.lastElementChild.append(" ", note); }322+ note.textContent = text;323+ }324+325+ value(row) { return row.dataset.value === "server" ? this.getAttribute("server") : this.getAttribute("relay"); }326+327+ normal(value) { return String(value || "").trim().replace(/\/+$/, "").toLowerCase(); }328+329+ // socket opens one websocket, runs fn with send and a message queue, then closes.330+ socket(url, fn, timeout = 8000) {331+ return new Promise((resolve, reject) => {332+ let ws;333+ try { ws = new WebSocket(url); } catch (err) { reject(err); return; }334+ const timer = setTimeout(() => { ws.close(); reject(Error("timed out talking to " + url)); }, timeout);335+ const done = (value, err) => { clearTimeout(timer); ws.close(); err ? reject(err) : resolve(value); };336+ ws.addEventListener("error", () => done(null, Error("could not reach " + url)));337+ ws.addEventListener("open", () => fn(message => ws.send(JSON.stringify(message)), handler => ws.addEventListener("message", e => handler(JSON.parse(e.data))), done));338+ });339+ }340+341+ current(row) {342+ const kind = Number(row.dataset.kind);343+ return this.socket(this.getAttribute("relay"), (send, onMessage, done) => {344+ let latest = null;345+ onMessage(message => {346+ if (message[0] === "EVENT" && message[2]?.kind === kind && (!latest || message[2].created_at > latest.created_at)) latest = message[2];347+ if (message[0] === "EOSE") done(latest);348+ });349+ send(["REQ", "lists", {kinds: [kind], authors: [this.getAttribute("pubkey")], limit: 1}]);350+ });351+ }352+353+ entries(event, tag) {354+ return (event?.tags || []).filter(t => t[0] === tag).map(t => t[1]);355+ }356+357+ async check(row) {358+ this.say(row, "checking…");359+ const event = await this.current(row);360+ if (!event) { this.say(row, "no list published here yet"); return; }361+ const listed = this.entries(event, row.dataset.tag).some(v => this.normal(v) === this.normal(this.value(row)));362+ this.say(row, listed ? "listed" : "not listed, " + this.entries(event, row.dataset.tag).length + " other entries");363+ }364+365+ async add(row) {366+ if (!window.nostr?.signEvent) throw Error("connect a signer first");367+ this.say(row, "reading…");368+ const existing = await this.current(row);369+ const value = this.value(row);370+ const tags = (existing?.tags || []).filter(t => t.length);371+ if (!this.entries(existing, row.dataset.tag).some(v => this.normal(v) === this.normal(value))) tags.push([row.dataset.tag, value]);372+ const event = await window.nostr.signEvent({kind: Number(row.dataset.kind), created_at: Math.floor(Date.now() / 1000), content: existing?.content || "", tags});373+ const targets = [this.getAttribute("relay")];374+ for (const t of tags) if ((t[0] === "r" || t[0] === "relay") && /^wss?:\/\//i.test(t[1]) && !targets.includes(t[1])) targets.push(t[1]);375+ let accepted = 0;376+ const failures = [];377+ for (const url of targets) {378+ try {379+ await this.socket(url, (send, onMessage, done) => {380+ onMessage(message => { if (message[0] === "OK" && message[1] === event.id) message[2] ? done(true) : done(null, Error(message[3] || "rejected")); });381+ send(["EVENT", event]);382+ }, 6000);383+ accepted++;384+ } catch (err) {385+ failures.push(url.replace(/^wss?:\/\//, ""));386+ }387+ }388+ if (!accepted) throw Error("no relay accepted the list: " + failures.join(", "));389+ this.say(row, "listed, published to " + accepted + " of " + targets.length + " relays" + (failures.length ? " (failed: " + failures.join(", ") + ")" : ""));390+ }391+ }392+299393 customElements.define("rpc-form", RpcForm);394+ customElements.define("relay-lists", RelayLists);300395 customElements.define("signed-form", SignedForm);301396 customElements.define("publish-list", PublishList);302397 customElements.define("nostr-key", NostrKey);
M internal/webui/public.html +20 -0
@@ -11,6 +11,13 @@
1111 </table>1212 {{if .Policy.Description}}<h2>About</h2>1313 {{markdown .Policy.Description}}14+{{end}}{{if .Connections}}<h2>Connections</h2>15+<table id="rows">16+<thead><tr><th>connection</th><th>relay</th><th>status</th></tr></thead>17+<tbody>18+{{range .Connections}}{{$c := asMap .}}<tr><td>{{or (str (index $c "label")) (str (index $c "title")) (str (index $c "name")) (str (index $c "template"))}}</td><td><code>{{or (str (index $c "url")) (str (index $c "href")) (str (index $c "relay"))}}</code></td><td>{{or (str (index $c "status")) (str (index $c "visibility")) "configured"}}</td></tr>19+{{end}}</tbody>20+</table>1421 {{end}}<h2>Recent activity</h2>1522 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}1623 </section>
@@ -22,6 +29,19 @@
2229 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>2330 <p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/inbox">Inbox</a></p>2431 <p>Use the sign out control in the footer to end this session.</p>32+<h2>Your relay lists</h2>33+<p>Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>34+<relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}">35+<table id="rows">36+<tbody>37+<tr data-kind="10002" data-tag="r"><td>Relay list <small>NIP-65</small></td><td>where clients read your notes and send you mentions</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>38+<tr data-kind="10050" data-tag="relay"><td>DM inbox <small>NIP-17</small></td><td>where people send you private messages</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>39+<tr data-kind="10007" data-tag="relay"><td>Search relays <small>NIP-51</small></td><td>where clients run your searches</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>40+<tr data-kind="10063" data-tag="server" data-value="server"><td>Blossom servers <small>BUD-03</small></td><td>where clients upload and look for your files</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>41+</tbody>42+</table>43+<output role="status"></output>44+</relay-lists>2545 {{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>2646 {{template "signerControls" .}}2747 {{end}}</section>
M internal/webui/shell_test.go +40 -0
@@ -308,3 +308,43 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
308308 }309309 }310310 }311+312+type connectionsBackend struct{ fakeBackend }313+314+func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {315+ if method == "listconnections" {316+ return []any{map[string]any{"label": "Upstream", "url": "wss://upstream.example", "status": "syncing"}}, nil317+ }318+ return nil, nil319+}320+321+func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) {322+ backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}323+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})324+ if err != nil {325+ t.Fatal(err)326+ }327+ recorder := httptest.NewRecorder()328+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))329+ body := recorder.Body.String()330+ for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`, `customElements.define("relay-lists"`} {331+ if !strings.Contains(body, want) {332+ t.Errorf("account page missing %q", want)333+ }334+ }335+ recorder = httptest.NewRecorder()336+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))337+ body = recorder.Body.String()338+ if !strings.Contains(body, "<h2>Connections</h2>") || !strings.Contains(body, "wss://upstream.example") || !strings.Contains(body, "syncing") {339+ t.Fatalf("home page did not show configured connections: %s", body[:min(400, len(body))])340+ }341+ guest, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})342+ if err != nil {343+ t.Fatal(err)344+ }345+ recorder = httptest.NewRecorder()346+ guest.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))347+ if strings.Contains(recorder.Body.String(), "<h2>Connections</h2>") {348+ t.Fatal("guests must not see configured connections")349+ }350+}
M internal/webui/style.css +2 -0
M internal/webui/templates.go +12 -0
M internal/webui/webui.go +8 -0