tinyrelaycommit

Commit 5df27560c504

browse files at this commit | history

1diff --git a/internal/webui/components.js b/internal/webui/components.js
2index ad1ccf3..44fe1b1 100644
3--- a/internal/webui/components.js
4+++ b/internal/webui/components.js
5@@ -296,7 +296,102 @@
6 }
7 }
8
9+ // RelayLists reads the signed-in key's relay lists from this relay, says
10+ // whether this relay is on each, and republishes a list with this relay
11+ // added. Lists are replaceable events, so the new one carries every
12+ // existing entry plus this relay. Each publish goes to this relay and to
13+ // the relays already on the list so other clients find the update.
14+ class RelayLists extends HTMLElement {
15+ connectedCallback() {
16+ if (this.bound) return;
17+ this.bound = true;
18+ this.output = this.querySelector("output");
19+ this.addEventListener("click", event => {
20+ const button = event.target.closest("button[name]");
21+ const row = button?.closest("tr[data-kind]");
22+ if (!button || !row) return;
23+ button.disabled = true;
24+ const action = button.name === "add" ? this.add(row) : this.check(row);
25+ action.catch(err => this.say(row, "error: " + err.message)).finally(() => { button.disabled = false; });
26+ });
27+ }
28+
29+ say(row, text) {
30+ let note = row.querySelector("output");
31+ if (!note) { note = el("output"); row.lastElementChild.append(" ", note); }
32+ note.textContent = text;
33+ }
34+
35+ value(row) { return row.dataset.value === "server" ? this.getAttribute("server") : this.getAttribute("relay"); }
36+
37+ normal(value) { return String(value || "").trim().replace(/\/+$/, "").toLowerCase(); }
38+
39+ // socket opens one websocket, runs fn with send and a message queue, then closes.
40+ socket(url, fn, timeout = 8000) {
41+ return new Promise((resolve, reject) => {
42+ let ws;
43+ try { ws = new WebSocket(url); } catch (err) { reject(err); return; }
44+ const timer = setTimeout(() => { ws.close(); reject(Error("timed out talking to " + url)); }, timeout);
45+ const done = (value, err) => { clearTimeout(timer); ws.close(); err ? reject(err) : resolve(value); };
46+ ws.addEventListener("error", () => done(null, Error("could not reach " + url)));
47+ ws.addEventListener("open", () => fn(message => ws.send(JSON.stringify(message)), handler => ws.addEventListener("message", e => handler(JSON.parse(e.data))), done));
48+ });
49+ }
50+
51+ current(row) {
52+ const kind = Number(row.dataset.kind);
53+ return this.socket(this.getAttribute("relay"), (send, onMessage, done) => {
54+ let latest = null;
55+ onMessage(message => {
56+ if (message[0] === "EVENT" && message[2]?.kind === kind && (!latest || message[2].created_at > latest.created_at)) latest = message[2];
57+ if (message[0] === "EOSE") done(latest);
58+ });
59+ send(["REQ", "lists", {kinds: [kind], authors: [this.getAttribute("pubkey")], limit: 1}]);
60+ });
61+ }
62+
63+ entries(event, tag) {
64+ return (event?.tags || []).filter(t => t[0] === tag).map(t => t[1]);
65+ }
66+
67+ async check(row) {
68+ this.say(row, "checking…");
69+ const event = await this.current(row);
70+ if (!event) { this.say(row, "no list published here yet"); return; }
71+ const listed = this.entries(event, row.dataset.tag).some(v => this.normal(v) === this.normal(this.value(row)));
72+ this.say(row, listed ? "listed" : "not listed, " + this.entries(event, row.dataset.tag).length + " other entries");
73+ }
74+
75+ async add(row) {
76+ if (!window.nostr?.signEvent) throw Error("connect a signer first");
77+ this.say(row, "reading…");
78+ const existing = await this.current(row);
79+ const value = this.value(row);
80+ const tags = (existing?.tags || []).filter(t => t.length);
81+ if (!this.entries(existing, row.dataset.tag).some(v => this.normal(v) === this.normal(value))) tags.push([row.dataset.tag, value]);
82+ const event = await window.nostr.signEvent({kind: Number(row.dataset.kind), created_at: Math.floor(Date.now() / 1000), content: existing?.content || "", tags});
83+ const targets = [this.getAttribute("relay")];
84+ 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]);
85+ let accepted = 0;
86+ const failures = [];
87+ for (const url of targets) {
88+ try {
89+ await this.socket(url, (send, onMessage, done) => {
90+ onMessage(message => { if (message[0] === "OK" && message[1] === event.id) message[2] ? done(true) : done(null, Error(message[3] || "rejected")); });
91+ send(["EVENT", event]);
92+ }, 6000);
93+ accepted++;
94+ } catch (err) {
95+ failures.push(url.replace(/^wss?:\/\//, ""));
96+ }
97+ }
98+ if (!accepted) throw Error("no relay accepted the list: " + failures.join(", "));
99+ this.say(row, "listed, published to " + accepted + " of " + targets.length + " relays" + (failures.length ? " (failed: " + failures.join(", ") + ")" : ""));
100+ }
101+ }
102+
103 customElements.define("rpc-form", RpcForm);
104+ customElements.define("relay-lists", RelayLists);
105 customElements.define("signed-form", SignedForm);
106 customElements.define("publish-list", PublishList);
107 customElements.define("nostr-key", NostrKey);
108diff --git a/internal/webui/public.html b/internal/webui/public.html
109index e87a385..b75d99b 100644
110--- a/internal/webui/public.html
111+++ b/internal/webui/public.html
112@@ -11,6 +11,13 @@
113 </table>
114 {{if .Policy.Description}}<h2>About</h2>
115 {{markdown .Policy.Description}}
116+{{end}}{{if .Connections}}<h2>Connections</h2>
117+<table id="rows">
118+<thead><tr><th>connection</th><th>relay</th><th>status</th></tr></thead>
119+<tbody>
120+{{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>
121+{{end}}</tbody>
122+</table>
123 {{end}}<h2>Recent activity</h2>
124 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}
125 </section>
126@@ -22,6 +29,19 @@
127 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>
128 <p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/inbox">Inbox</a></p>
129 <p>Use the sign out control in the footer to end this session.</p>
130+<h2>Your relay lists</h2>
131+<p>Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>
132+<relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}">
133+<table id="rows">
134+<tbody>
135+<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>
136+<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>
137+<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>
138+<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>
139+</tbody>
140+</table>
141+<output role="status"></output>
142+</relay-lists>
143 {{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>
144 {{template "signerControls" .}}
145 {{end}}</section>
146diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
147index 1d21ffc..4d571be 100644
148--- a/internal/webui/shell_test.go
149+++ b/internal/webui/shell_test.go
150@@ -308,3 +308,43 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
151 }
152 }
153 }
154+
155+type connectionsBackend struct{ fakeBackend }
156+
157+func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {
158+ if method == "listconnections" {
159+ return []any{map[string]any{"label": "Upstream", "url": "wss://upstream.example", "status": "syncing"}}, nil
160+ }
161+ return nil, nil
162+}
163+
164+func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) {
165+ backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
166+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ recorder := httptest.NewRecorder()
171+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))
172+ body := recorder.Body.String()
173+ 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"`} {
174+ if !strings.Contains(body, want) {
175+ t.Errorf("account page missing %q", want)
176+ }
177+ }
178+ recorder = httptest.NewRecorder()
179+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
180+ body = recorder.Body.String()
181+ if !strings.Contains(body, "<h2>Connections</h2>") || !strings.Contains(body, "wss://upstream.example") || !strings.Contains(body, "syncing") {
182+ t.Fatalf("home page did not show configured connections: %s", body[:min(400, len(body))])
183+ }
184+ guest, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+ recorder = httptest.NewRecorder()
189+ guest.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
190+ if strings.Contains(recorder.Body.String(), "<h2>Connections</h2>") {
191+ t.Fatal("guests must not see configured connections")
192+ }
193+}
194diff --git a/internal/webui/style.css b/internal/webui/style.css
195index de95946..2031612 100644
196--- a/internal/webui/style.css
197+++ b/internal/webui/style.css
198@@ -110,6 +110,8 @@ rpc-form output,signed-form output,publish-list output{display:block;font:12px v
199 rpc-form output[data-error]{color:var(--alert)}
200 #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px}
201
202+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}
203+
204 /* json-view: structured rendering of any JSON payload. */
205 json-view{display:block;font:12px var(--mono)}
206 json-view>details>summary{cursor:pointer;color:var(--muted)}
207diff --git a/internal/webui/templates.go b/internal/webui/templates.go
208index 974c59a..70d71b2 100644
209--- a/internal/webui/templates.go
210+++ b/internal/webui/templates.go
211@@ -82,6 +82,17 @@ func promptPath(path string, query url.Values) string {
212 return path
213 }
214
215+// wsURL turns the relay's public http(s) URL into its websocket form.
216+func wsURL(base string) string {
217+ switch {
218+ case strings.HasPrefix(base, "https://"):
219+ return "wss://" + strings.TrimPrefix(base, "https://")
220+ case strings.HasPrefix(base, "http://"):
221+ return "ws://" + strings.TrimPrefix(base, "http://")
222+ }
223+ return base
224+}
225+
226 // short renders a Unix timestamp as a compact UTC stamp for list rows.
227 func short(value any) string {
228 seconds := unixSeconds(value)
229@@ -125,6 +136,7 @@ func parseTemplates() (*template.Template, error) {
230 "markdown": renderMarkdown,
231 "hasPrefix": strings.HasPrefix,
232 "npub": identityNpub,
233+ "wsURL": wsURL,
234 "short": short,
235 "prompt": promptPath,
236 "railKind": railKind,
237diff --git a/internal/webui/webui.go b/internal/webui/webui.go
238index 6ab88ec..93afdce 100644
239--- a/internal/webui/webui.go
240+++ b/internal/webui/webui.go
241@@ -79,6 +79,9 @@ type PageData struct {
242 Path string
243 Readme template.HTML
244 Tree []any
245+ // Connections lists the relay's configured connections for signed-in
246+ // owners and moderators; it stays nil for everyone else.
247+ Connections []any
248 }
249
250 func New(backend Backend, options Options) (*App, error) {
251@@ -769,6 +772,11 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
252 data.Feed = feed
253 }
254 }
255+ if tab == "home" && actor != "" {
256+ if result, err := a.backend.Query(request.Context(), "listconnections", nil, actor); err == nil {
257+ data.Connections = browseRows(result)
258+ }
259+ }
260 if tab != "" {
261 data.Title = tab + " | " + a.backend.Slug()
262 }

About

ownerc01503e8035f
visibilitypublic
head5df27560c504

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs