tinyrelaycommit

Commit 65595eb0a21b

browse files at this commit | history

1diff --git a/internal/webui/components.js b/internal/webui/components.js
2index 44fe1b1..1eb60aa 100644
3--- a/internal/webui/components.js
4+++ b/internal/webui/components.js
5@@ -390,7 +390,29 @@
6 }
7 }
8
9+ // ConnectCard copies a command or address, filling {input:name} from the
10+ // card's inputs, so a clone command carries the repository name typed.
11+ class ConnectCard extends HTMLElement {
12+ connectedCallback() {
13+ if (this.bound) return;
14+ this.bound = true;
15+ this.addEventListener("click", async event => {
16+ const button = event.target.closest("button[data-copy]");
17+ if (!button) return;
18+ const text = button.dataset.copy.replace(/\{input:([a-z]+)\}/g, (match, name) => this.querySelector(`input[name="${name}"]`)?.value.trim() || "<" + name + ">");
19+ const output = this.querySelector("output");
20+ try {
21+ await navigator.clipboard.writeText(text);
22+ if (output) output.textContent = "copied: " + text;
23+ } catch {
24+ if (output) output.textContent = text;
25+ }
26+ });
27+ }
28+ }
29+
30 customElements.define("rpc-form", RpcForm);
31+ customElements.define("connect-card", ConnectCard);
32 customElements.define("relay-lists", RelayLists);
33 customElements.define("signed-form", SignedForm);
34 customElements.define("publish-list", PublishList);
35diff --git a/internal/webui/connections.go b/internal/webui/connections.go
36new file mode 100644
37index 0000000..6437e35
38--- /dev/null
39+++ b/internal/webui/connections.go
40@@ -0,0 +1,159 @@
41+package webui
42+
43+import (
44+ "encoding/binary"
45+ "encoding/hex"
46+ "net/url"
47+ "regexp"
48+ "strings"
49+)
50+
51+// bech32 encodes data with the given human readable part.
52+func bech32(hrp string, data []byte) string {
53+ fiveBits := convertBits(data)
54+ expanded := make([]byte, 0, len(hrp)*2+1)
55+ for _, c := range hrp {
56+ expanded = append(expanded, byte(c)>>5)
57+ }
58+ expanded = append(expanded, 0)
59+ for _, c := range hrp {
60+ expanded = append(expanded, byte(c)&31)
61+ }
62+ values := append(append(append([]byte{}, expanded...), fiveBits...), 0, 0, 0, 0, 0, 0)
63+ polymod := bech32Polymod(values) ^ 1
64+ checksum := make([]byte, 6)
65+ for i := range checksum {
66+ checksum[i] = byte(polymod >> uint(5*(5-i)) & 31)
67+ }
68+ var out strings.Builder
69+ out.WriteString(hrp + "1")
70+ for _, value := range append(fiveBits, checksum...) {
71+ out.WriteByte(bech32Charset[value])
72+ }
73+ return out.String()
74+}
75+
76+func tlv(kind byte, value []byte) []byte {
77+ return append([]byte{kind, byte(len(value))}, value...)
78+}
79+
80+// nprofile encodes a NIP-19 profile pointer with one relay hint.
81+func nprofile(pubkeyHex, relay string) string {
82+ pubkey, err := hex.DecodeString(pubkeyHex)
83+ if err != nil || len(pubkey) != 32 {
84+ return ""
85+ }
86+ data := tlv(0, pubkey)
87+ if relay != "" {
88+ data = append(data, tlv(1, []byte(relay))...)
89+ }
90+ return bech32("nprofile", data)
91+}
92+
93+// naddr encodes a NIP-19 address pointer for a replaceable event.
94+func naddr(kind uint32, pubkeyHex, identifier, relay string) string {
95+ pubkey, err := hex.DecodeString(pubkeyHex)
96+ if err != nil || len(pubkey) != 32 {
97+ return ""
98+ }
99+ data := tlv(0, []byte(identifier))
100+ if relay != "" {
101+ data = append(data, tlv(1, []byte(relay))...)
102+ }
103+ data = append(data, tlv(2, pubkey)...)
104+ kindBytes := make([]byte, 4)
105+ binary.BigEndian.PutUint32(kindBytes, kind)
106+ data = append(data, tlv(3, kindBytes)...)
107+ return bech32("naddr", data)
108+}
109+
110+var placeholder = regexp.MustCompile(`\{([a-z]+):([a-z]+)(?:\|(enc))?\}`)
111+
112+// connectionValues resolves the placeholders connection templates use:
113+// relay:url, relay:web, relay:host, relay:domain, relay:naddr, owner:npub,
114+// owner:nprofile, user:npub and user:nprofile. The relay's group address is
115+// its NIP-29 top-level group, published under the relay identity.
116+func connectionValues(data PageData) map[string]string {
117+ values := map[string]string{}
118+ web := strings.TrimRight(data.URL, "/")
119+ values["relay:web"] = web
120+ values["relay:url"] = wsURL(web)
121+ if parsed, err := url.Parse(web); err == nil {
122+ values["relay:host"] = parsed.Host
123+ values["relay:domain"] = parsed.Hostname()
124+ }
125+ if data.Identity != "" {
126+ values["relay:naddr"] = naddr(39000, data.Identity, "_", values["relay:url"])
127+ }
128+ if data.Policy.Owner != "" {
129+ values["owner:npub"] = identityNpub(data.Policy.Owner)
130+ values["owner:nprofile"] = nprofile(data.Policy.Owner, values["relay:url"])
131+ }
132+ if data.Actor != "" {
133+ values["user:npub"] = identityNpub(data.Actor)
134+ values["user:nprofile"] = nprofile(data.Actor, values["relay:url"])
135+ }
136+ return values
137+}
138+
139+// fillPlaceholders substitutes known values, leaving input placeholders for
140+// the page script. It reports false when a needed value is missing, such as
141+// a user value for a guest, so the link can be left out.
142+func fillPlaceholders(text string, values map[string]string) (string, bool) {
143+ complete := true
144+ out := placeholder.ReplaceAllStringFunc(text, func(match string) string {
145+ parts := placeholder.FindStringSubmatch(match)
146+ if parts[1] == "input" {
147+ return match
148+ }
149+ value, ok := values[parts[1]+":"+parts[2]]
150+ if !ok || value == "" {
151+ complete = false
152+ return ""
153+ }
154+ if parts[3] == "enc" {
155+ return url.QueryEscape(value)
156+ }
157+ return value
158+ })
159+ return out, complete
160+}
161+
162+// expandConnections turns the daemon's connection rows into ready-to-render
163+// maps with placeholders resolved. Links that need a signed-in user are
164+// dropped for guests; the card keeps a note that signing in reveals them.
165+func expandConnections(rows []any, data PageData) []any {
166+ values := connectionValues(data)
167+ out := make([]any, 0, len(rows))
168+ for _, row := range rows {
169+ item := valueMap(row)
170+ card := map[string]any{"name": plainString(item["template"]), "title": plainString(item["title"]), "about": plainString(item["about"]), "app": plainString(item["app"]), "where": plainString(item["where"]), "visibility": plainString(item["visibility"]), "inputs": item["inputs"]}
171+ if card["title"] == "" {
172+ card["title"] = card["name"]
173+ }
174+ if qr, ok := fillPlaceholders(plainString(item["qr"]), values); ok && qr != "" {
175+ card["qr"] = qr
176+ }
177+ links := []any{}
178+ hidden := 0
179+ for _, link := range browseRows(item["links"]) {
180+ l := valueMap(link)
181+ field := func(name string) string {
182+ if value := plainString(l[name]); value != "" {
183+ return value
184+ }
185+ return plainString(l[strings.ToUpper(name[:1])+name[1:]])
186+ }
187+ href, okHref := fillPlaceholders(field("href"), values)
188+ copyText, okCopy := fillPlaceholders(field("copy"), values)
189+ if !okHref || !okCopy {
190+ hidden++
191+ continue
192+ }
193+ links = append(links, map[string]any{"label": field("label"), "href": href, "copy": copyText})
194+ }
195+ card["links"], card["hidden"] = links, hidden
196+ out = append(out, card)
197+ }
198+ return out
199+}
200diff --git a/internal/webui/connections_test.go b/internal/webui/connections_test.go
201new file mode 100644
202index 0000000..3b5fd2e
203--- /dev/null
204+++ b/internal/webui/connections_test.go
205@@ -0,0 +1,63 @@
206+package webui
207+
208+import (
209+ "strings"
210+ "testing"
211+
212+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
213+)
214+
215+func TestNIP19PointersMatchReferenceEncodings(t *testing.T) {
216+ key := strings.Repeat("a", 64)
217+ if got := nprofile(key, "wss://relay.example"); got != "nprofile1qqs242424242424242424242424242424242424242424242424242spzdmhxue69uhhyetvv9ujuetcv9khqmr9nxcavj" {
218+ t.Fatalf("nprofile = %s", got)
219+ }
220+ if got := naddr(39000, key, "_", "wss://relay.example"); got != "naddr1qqq47qgnwaehxw309aex2mrp0yhx27rpd4cxcegzyz424242424242424242424242424242424242424242424242425qcyqqqfskquzv50g" {
221+ t.Fatalf("naddr = %s", got)
222+ }
223+ if got := bech32("npub", mustHex(key)); got != "npub1424242424242424242424242424242424242424242424242424qamrcaj" {
224+ t.Fatalf("npub = %s", got)
225+ }
226+}
227+
228+func TestConnectionsExpandPlaceholdersAndHideUserLinksForGuests(t *testing.T) {
229+ data := PageData{URL: "https://relay.example", Identity: strings.Repeat("a", 64), Policy: policy.Defaults(strings.Repeat("b", 64))}
230+ rows := []any{map[string]any{"template": "repos", "title": "Repos", "visibility": "public", "links": []any{
231+ map[string]any{"label": "Open", "href": "https://gitworkshop.dev/relay/{relay:host|enc}"},
232+ map[string]any{"Label": "Copy clone command", "Copy": "git clone '{relay:web}/{user:npub}/{input:repo}.git'"},
233+ }}, map[string]any{"template": "group", "title": "Group", "qr": "nostr:{relay:naddr}", "links": []any{map[string]any{"label": "Open", "href": "https://app.flotilla.social/spaces/{relay:host|enc}"}}}}
234+ cards := expandConnections(rows, data)
235+ repos := valueMap(cards[0])
236+ links := browseRows(repos["links"])
237+ if len(links) != 1 || valueMap(links[0])["href"] != "https://gitworkshop.dev/relay/relay.example" || repos["hidden"] != 1 {
238+ t.Fatalf("guest repos card = %v", repos)
239+ }
240+ group := valueMap(cards[1])
241+ if !strings.HasPrefix(plainString(group["qr"]), "nostr:naddr1") {
242+ t.Fatalf("group qr = %v", group["qr"])
243+ }
244+ data.Actor = strings.Repeat("c", 64)
245+ cards = expandConnections(rows, data)
246+ links = browseRows(valueMap(cards[0])["links"])
247+ if len(links) != 2 || !strings.Contains(plainString(valueMap(links[1])["copy"]), "/npub1enxvenxv") || !strings.Contains(plainString(valueMap(links[1])["copy"]), "{input:repo}") {
248+ t.Fatalf("member repos links = %v", links)
249+ }
250+}
251+
252+func mustHex(value string) []byte {
253+ out := make([]byte, len(value)/2)
254+ for i := range out {
255+ out[i] = hexNibble(value[2*i])<<4 | hexNibble(value[2*i+1])
256+ }
257+ return out
258+}
259+
260+func hexNibble(c byte) byte {
261+ switch {
262+ case c >= '0' && c <= '9':
263+ return c - '0'
264+ case c >= 'a' && c <= 'f':
265+ return c - 'a' + 10
266+ }
267+ return 0
268+}
269diff --git a/internal/webui/public.html b/internal/webui/public.html
270index b75d99b..d5e6e89 100644
271--- a/internal/webui/public.html
272+++ b/internal/webui/public.html
273@@ -11,14 +11,16 @@
274 </table>
275 {{if .Policy.Description}}<h2>About</h2>
276 {{markdown .Policy.Description}}
277-{{end}}{{if .Connections}}<h2>Connections</h2>
278-<table id="rows">
279-<thead><tr><th>connection</th><th>relay</th><th>status</th></tr></thead>
280-<tbody>
281-{{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>
282-{{end}}</tbody>
283-</table>
284-{{end}}<h2>Recent activity</h2>
285+{{end}}{{if .Connections}}<h2>Connect</h2>
286+<p>Open this relay in an app. Each card is a way in; sign in to see the ones made for members.</p>
287+{{range .Connections}}{{$c := asMap .}}<connect-card>
288+<h3>{{str (index $c "title")}} <small>{{str (index $c "app")}}{{with str (index $c "where")}}, {{.}}{{end}}{{if ne (str (index $c "visibility")) "public"}} | {{str (index $c "visibility")}}{{end}}</small></h3>
289+<p>{{str (index $c "about")}}</p>
290+{{range index $c "inputs"}}{{$i := asMap .}}<label>{{str (index $i "Label")}} <input name="{{str (index $i "Name")}}" placeholder="{{str (index $i "Placeholder")}}" pattern="{{str (index $i "Pattern")}}"></label>
291+{{end}}<p>{{range index $c "links"}}{{$l := asMap .}}{{if str (index $l "href")}}<a href="{{str (index $l "href")}}">{{str (index $l "label")}}</a> {{else}}<button type="button" data-copy="{{str (index $l "copy")}}">{{str (index $l "label")}}</button> {{end}}{{end}}{{if index $c "hidden"}}<small>sign in for {{str (index $c "hidden")}} more</small>{{end}}<output role="status"></output></p>
292+{{with str (index $c "qr")}}<details><summary>QR code</summary><img src="/qr.svg?text={{urlquery .}}" alt="QR code" width="200" height="200"></details>{{end}}
293+</connect-card>
294+{{end}}{{end}}<h2>Recent activity</h2>
295 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}
296 </section>
297 {{end}}
298diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
299index 4d571be..3ac1755 100644
300--- a/internal/webui/shell_test.go
301+++ b/internal/webui/shell_test.go
302@@ -312,13 +312,13 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
303 type connectionsBackend struct{ fakeBackend }
304
305 func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {
306- if method == "listconnections" {
307- return []any{map[string]any{"label": "Upstream", "url": "wss://upstream.example", "status": "syncing"}}, nil
308+ if method == "connections" {
309+ return []any{map[string]any{"template": "notes", "title": "Notes", "about": "Everything posted here.", "app": "Jumble", "where": "web", "visibility": "public", "links": []any{map[string]any{"label": "Open", "href": "https://jumble.social/?r={relay:url|enc}"}}}}, nil
310 }
311 return nil, nil
312 }
313
314-func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) {
315+func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) {
316 backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
317 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
318 if err != nil {
319@@ -335,16 +335,7 @@ func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) {
320 recorder = httptest.NewRecorder()
321 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
322 body = recorder.Body.String()
323- if !strings.Contains(body, "<h2>Connections</h2>") || !strings.Contains(body, "wss://upstream.example") || !strings.Contains(body, "syncing") {
324- t.Fatalf("home page did not show configured connections: %s", body[:min(400, len(body))])
325- }
326- guest, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
327- if err != nil {
328- t.Fatal(err)
329- }
330- recorder = httptest.NewRecorder()
331- guest.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
332- if strings.Contains(recorder.Body.String(), "<h2>Connections</h2>") {
333- t.Fatal("guests must not see configured connections")
334+ if !strings.Contains(body, "<h2>Connect</h2>") || !strings.Contains(body, `href="https://jumble.social/?r=ws%3A%2F%2Frelay.example"`) || !strings.Contains(body, "<connect-card>") {
335+ t.Fatalf("home page did not show connect cards: %s", body[:min(400, len(body))])
336 }
337 }
338diff --git a/internal/webui/style.css b/internal/webui/style.css
339index 2031612..4d059e7 100644
340--- a/internal/webui/style.css
341+++ b/internal/webui/style.css
342@@ -112,6 +112,8 @@ rpc-form output[data-error]{color:var(--alert)}
343
344 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}
345
346+connect-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}connect-card h3{margin:0 0 4px;border:0;font-size:15px}connect-card h3 small{font:12px var(--mono);color:var(--muted)}connect-card p{margin:0 0 6px}connect-card label{display:inline-block;font:12px var(--mono);color:var(--muted);margin:0 10px 6px 0}connect-card label input{display:block;font:13px var(--mono);min-width:200px}connect-card a,connect-card button{margin-right:10px;font-size:13px}connect-card output{display:block;font:12px var(--mono);color:var(--muted);overflow-wrap:anywhere}connect-card details img{display:block;margin:6px 0;background:#fff;padding:6px;border:1px solid var(--line)}
347+
348 /* json-view: structured rendering of any JSON payload. */
349 json-view{display:block;font:12px var(--mono)}
350 json-view>details>summary{cursor:pointer;color:var(--muted)}
351diff --git a/internal/webui/webui.go b/internal/webui/webui.go
352index 93afdce..dfbf966 100644
353--- a/internal/webui/webui.go
354+++ b/internal/webui/webui.go
355@@ -79,8 +79,8 @@ type PageData struct {
356 Path string
357 Readme template.HTML
358 Tree []any
359- // Connections lists the relay's configured connections for signed-in
360- // owners and moderators; it stays nil for everyone else.
361+ // Connections lists the ways to open this relay in client apps that the
362+ // viewer may see, with link placeholders already resolved.
363 Connections []any
364 }
365
366@@ -680,21 +680,7 @@ func identityNpub(identity string) string {
367
368 const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
369
370-func encodeNpub(data []byte) string {
371- fiveBits := convertBits(data)
372- values := append(append([]byte{}, fiveBits...), 0, 0, 0, 0, 0, 0)
373- polymod := bech32Polymod(append([]byte{3, 3, 3, 3, 0, 14, 16, 21, 2}, values...)) ^ 1
374- checksum := make([]byte, 6)
375- for i := range checksum {
376- checksum[i] = byte(polymod >> uint(5*(5-i)) & 31)
377- }
378- var out strings.Builder
379- out.WriteString("npub1")
380- for _, value := range append(fiveBits, checksum...) {
381- out.WriteByte(bech32Charset[value])
382- }
383- return out.String()
384-}
385+func encodeNpub(data []byte) string { return bech32("npub", data) }
386
387 func convertBits(data []byte) []byte {
388 result := make([]byte, 0, 52)
389@@ -772,9 +758,9 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
390 data.Feed = feed
391 }
392 }
393- if tab == "home" && actor != "" {
394- if result, err := a.backend.Query(request.Context(), "listconnections", nil, actor); err == nil {
395- data.Connections = browseRows(result)
396+ if tab == "home" {
397+ if result, err := a.backend.Query(request.Context(), "connections", nil, actor); err == nil {
398+ data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor})
399 }
400 }
401 if tab != "" {

About

ownerc01503e8035f
visibilitypublic
head65595eb0a21b

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs