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>
| M | internal/ | 95 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 20 | ++++++ |
| M | internal/ | 40 | +++++++++++++ |
| M | internal/ | 2 | + |
| M | internal/ | 12 | ++++ |
| M | internal/ | 8 | +++ |
@@ -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);@@ -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>@@ -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+}@@ -110,6 +110,8 @@ rpc-form output,signed-form output,publish-list output{display:block;font:12px v
110110 rpc-form output[data-error]{color:var(--alert)}111111 #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px}112112 113+relay-lists{display:block}relay-lists #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}relay-lists #rows td:last-child{width:230px;text-align:left;color:var(--ink);white-space:nowrap}relay-lists #rows td:first-child{width:170px;color:var(--ink);font-family:var(--sans)}relay-lists output{display:block;font:12px var(--mono);color:var(--muted);white-space:normal}relay-lists button{font-size:12px}114+113115 /* json-view: structured rendering of any JSON payload. */114116 json-view{display:block;font:12px var(--mono)}115117 json-view>details>summary{cursor:pointer;color:var(--muted)}@@ -82,6 +82,17 @@ func promptPath(path string, query url.Values) string {
8282 return path8383 }8484 85+// wsURL turns the relay's public http(s) URL into its websocket form.86+func wsURL(base string) string {87+ switch {88+ case strings.HasPrefix(base, "https://"):89+ return "wss://" + strings.TrimPrefix(base, "https://")90+ case strings.HasPrefix(base, "http://"):91+ return "ws://" + strings.TrimPrefix(base, "http://")92+ }93+ return base94+}95+8596 // short renders a Unix timestamp as a compact UTC stamp for list rows.8697 func short(value any) string {8798 seconds := unixSeconds(value)@@ -125,6 +136,7 @@ func parseTemplates() (*template.Template, error) {
125136 "markdown": renderMarkdown,126137 "hasPrefix": strings.HasPrefix,127138 "npub": identityNpub,139+ "wsURL": wsURL,128140 "short": short,129141 "prompt": promptPath,130142 "railKind": railKind,@@ -79,6 +79,9 @@ type PageData struct {
7979 Path string8080 Readme template.HTML8181 Tree []any82+ // Connections lists the relay's configured connections for signed-in83+ // owners and moderators; it stays nil for everyone else.84+ Connections []any8285 }8386 8487 func New(backend Backend, options Options) (*App, error) {@@ -769,6 +772,11 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
769772 data.Feed = feed770773 }771774 }775+ if tab == "home" && actor != "" {776+ if result, err := a.backend.Query(request.Context(), "listconnections", nil, actor); err == nil {777+ data.Connections = browseRows(result)778+ }779+ }772780 if tab != "" {773781 data.Title = tab + " | " + a.backend.Slug()774782 }