Each configured connection renders as a card with its app, links, copy commands and QR code. Placeholders resolve to the relay URL, host and group address, the owner's npub and nprofile, and the signed-in user's; links that need a signed-in user are counted for guests instead. NIP-19 nprofile and naddr encoding joins the existing npub encoder.
- commit
- 65595eb0a21b4e851d3970702715266cbcd69745
- parent
- 591f26270893b3a17c7fb23aa569adc46323946e
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 22 | ++++ |
| A | internal/ | 159 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 63 | ++++++++++++ |
| M | internal/ | 18 | ++-- |
| M | internal/ | 19 | +--- |
| M | internal/ | 2 | + |
| M | internal/ | 26 | +---- |
@@ -390,7 +390,29 @@
390390 }391391 }392392 393+ // ConnectCard copies a command or address, filling {input:name} from the394+ // card's inputs, so a clone command carries the repository name typed.395+ class ConnectCard extends HTMLElement {396+ connectedCallback() {397+ if (this.bound) return;398+ this.bound = true;399+ this.addEventListener("click", async event => {400+ const button = event.target.closest("button[data-copy]");401+ if (!button) return;402+ const text = button.dataset.copy.replace(/\{input:([a-z]+)\}/g, (match, name) => this.querySelector(`input[name="${name}"]`)?.value.trim() || "<" + name + ">");403+ const output = this.querySelector("output");404+ try {405+ await navigator.clipboard.writeText(text);406+ if (output) output.textContent = "copied: " + text;407+ } catch {408+ if (output) output.textContent = text;409+ }410+ });411+ }412+ }413+393414 customElements.define("rpc-form", RpcForm);415+ customElements.define("connect-card", ConnectCard);394416 customElements.define("relay-lists", RelayLists);395417 customElements.define("signed-form", SignedForm);396418 customElements.define("publish-list", PublishList);@@ -0,0 +1,159 @@
1+package webui2+3+import (4+ "encoding/binary"5+ "encoding/hex"6+ "net/url"7+ "regexp"8+ "strings"9+)10+11+// bech32 encodes data with the given human readable part.12+func bech32(hrp string, data []byte) string {13+ fiveBits := convertBits(data)14+ expanded := make([]byte, 0, len(hrp)*2+1)15+ for _, c := range hrp {16+ expanded = append(expanded, byte(c)>>5)17+ }18+ expanded = append(expanded, 0)19+ for _, c := range hrp {20+ expanded = append(expanded, byte(c)&31)21+ }22+ values := append(append(append([]byte{}, expanded...), fiveBits...), 0, 0, 0, 0, 0, 0)23+ polymod := bech32Polymod(values) ^ 124+ checksum := make([]byte, 6)25+ for i := range checksum {26+ checksum[i] = byte(polymod >> uint(5*(5-i)) & 31)27+ }28+ var out strings.Builder29+ out.WriteString(hrp + "1")30+ for _, value := range append(fiveBits, checksum...) {31+ out.WriteByte(bech32Charset[value])32+ }33+ return out.String()34+}35+36+func tlv(kind byte, value []byte) []byte {37+ return append([]byte{kind, byte(len(value))}, value...)38+}39+40+// nprofile encodes a NIP-19 profile pointer with one relay hint.41+func nprofile(pubkeyHex, relay string) string {42+ pubkey, err := hex.DecodeString(pubkeyHex)43+ if err != nil || len(pubkey) != 32 {44+ return ""45+ }46+ data := tlv(0, pubkey)47+ if relay != "" {48+ data = append(data, tlv(1, []byte(relay))...)49+ }50+ return bech32("nprofile", data)51+}52+53+// naddr encodes a NIP-19 address pointer for a replaceable event.54+func naddr(kind uint32, pubkeyHex, identifier, relay string) string {55+ pubkey, err := hex.DecodeString(pubkeyHex)56+ if err != nil || len(pubkey) != 32 {57+ return ""58+ }59+ data := tlv(0, []byte(identifier))60+ if relay != "" {61+ data = append(data, tlv(1, []byte(relay))...)62+ }63+ data = append(data, tlv(2, pubkey)...)64+ kindBytes := make([]byte, 4)65+ binary.BigEndian.PutUint32(kindBytes, kind)66+ data = append(data, tlv(3, kindBytes)...)67+ return bech32("naddr", data)68+}69+70+var placeholder = regexp.MustCompile(`\{([a-z]+):([a-z]+)(?:\|(enc))?\}`)71+72+// connectionValues resolves the placeholders connection templates use:73+// relay:url, relay:web, relay:host, relay:domain, relay:naddr, owner:npub,74+// owner:nprofile, user:npub and user:nprofile. The relay's group address is75+// its NIP-29 top-level group, published under the relay identity.76+func connectionValues(data PageData) map[string]string {77+ values := map[string]string{}78+ web := strings.TrimRight(data.URL, "/")79+ values["relay:web"] = web80+ values["relay:url"] = wsURL(web)81+ if parsed, err := url.Parse(web); err == nil {82+ values["relay:host"] = parsed.Host83+ values["relay:domain"] = parsed.Hostname()84+ }85+ if data.Identity != "" {86+ values["relay:naddr"] = naddr(39000, data.Identity, "_", values["relay:url"])87+ }88+ if data.Policy.Owner != "" {89+ values["owner:npub"] = identityNpub(data.Policy.Owner)90+ values["owner:nprofile"] = nprofile(data.Policy.Owner, values["relay:url"])91+ }92+ if data.Actor != "" {93+ values["user:npub"] = identityNpub(data.Actor)94+ values["user:nprofile"] = nprofile(data.Actor, values["relay:url"])95+ }96+ return values97+}98+99+// fillPlaceholders substitutes known values, leaving input placeholders for100+// the page script. It reports false when a needed value is missing, such as101+// a user value for a guest, so the link can be left out.102+func fillPlaceholders(text string, values map[string]string) (string, bool) {103+ complete := true104+ out := placeholder.ReplaceAllStringFunc(text, func(match string) string {105+ parts := placeholder.FindStringSubmatch(match)106+ if parts[1] == "input" {107+ return match108+ }109+ value, ok := values[parts[1]+":"+parts[2]]110+ if !ok || value == "" {111+ complete = false112+ return ""113+ }114+ if parts[3] == "enc" {115+ return url.QueryEscape(value)116+ }117+ return value118+ })119+ return out, complete120+}121+122+// expandConnections turns the daemon's connection rows into ready-to-render123+// maps with placeholders resolved. Links that need a signed-in user are124+// dropped for guests; the card keeps a note that signing in reveals them.125+func expandConnections(rows []any, data PageData) []any {126+ values := connectionValues(data)127+ out := make([]any, 0, len(rows))128+ for _, row := range rows {129+ item := valueMap(row)130+ 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"]}131+ if card["title"] == "" {132+ card["title"] = card["name"]133+ }134+ if qr, ok := fillPlaceholders(plainString(item["qr"]), values); ok && qr != "" {135+ card["qr"] = qr136+ }137+ links := []any{}138+ hidden := 0139+ for _, link := range browseRows(item["links"]) {140+ l := valueMap(link)141+ field := func(name string) string {142+ if value := plainString(l[name]); value != "" {143+ return value144+ }145+ return plainString(l[strings.ToUpper(name[:1])+name[1:]])146+ }147+ href, okHref := fillPlaceholders(field("href"), values)148+ copyText, okCopy := fillPlaceholders(field("copy"), values)149+ if !okHref || !okCopy {150+ hidden++151+ continue152+ }153+ links = append(links, map[string]any{"label": field("label"), "href": href, "copy": copyText})154+ }155+ card["links"], card["hidden"] = links, hidden156+ out = append(out, card)157+ }158+ return out159+}@@ -0,0 +1,63 @@
1+package webui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/policy"8+)9+10+func TestNIP19PointersMatchReferenceEncodings(t *testing.T) {11+ key := strings.Repeat("a", 64)12+ if got := nprofile(key, "wss://relay.example"); got != "nprofile1qqs242424242424242424242424242424242424242424242424242spzdmhxue69uhhyetvv9ujuetcv9khqmr9nxcavj" {13+ t.Fatalf("nprofile = %s", got)14+ }15+ if got := naddr(39000, key, "_", "wss://relay.example"); got != "naddr1qqq47qgnwaehxw309aex2mrp0yhx27rpd4cxcegzyz424242424242424242424242424242424242424242424242425qcyqqqfskquzv50g" {16+ t.Fatalf("naddr = %s", got)17+ }18+ if got := bech32("npub", mustHex(key)); got != "npub1424242424242424242424242424242424242424242424242424qamrcaj" {19+ t.Fatalf("npub = %s", got)20+ }21+}22+23+func TestConnectionsExpandPlaceholdersAndHideUserLinksForGuests(t *testing.T) {24+ data := PageData{URL: "https://relay.example", Identity: strings.Repeat("a", 64), Policy: policy.Defaults(strings.Repeat("b", 64))}25+ rows := []any{map[string]any{"template": "repos", "title": "Repos", "visibility": "public", "links": []any{26+ map[string]any{"label": "Open", "href": "https://gitworkshop.dev/relay/{relay:host|enc}"},27+ map[string]any{"Label": "Copy clone command", "Copy": "git clone '{relay:web}/{user:npub}/{input:repo}.git'"},28+ }}, 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}"}}}}29+ cards := expandConnections(rows, data)30+ repos := valueMap(cards[0])31+ links := browseRows(repos["links"])32+ if len(links) != 1 || valueMap(links[0])["href"] != "https://gitworkshop.dev/relay/relay.example" || repos["hidden"] != 1 {33+ t.Fatalf("guest repos card = %v", repos)34+ }35+ group := valueMap(cards[1])36+ if !strings.HasPrefix(plainString(group["qr"]), "nostr:naddr1") {37+ t.Fatalf("group qr = %v", group["qr"])38+ }39+ data.Actor = strings.Repeat("c", 64)40+ cards = expandConnections(rows, data)41+ links = browseRows(valueMap(cards[0])["links"])42+ if len(links) != 2 || !strings.Contains(plainString(valueMap(links[1])["copy"]), "/npub1enxvenxv") || !strings.Contains(plainString(valueMap(links[1])["copy"]), "{input:repo}") {43+ t.Fatalf("member repos links = %v", links)44+ }45+}46+47+func mustHex(value string) []byte {48+ out := make([]byte, len(value)/2)49+ for i := range out {50+ out[i] = hexNibble(value[2*i])<<4 | hexNibble(value[2*i+1])51+ }52+ return out53+}54+55+func hexNibble(c byte) byte {56+ switch {57+ case c >= '0' && c <= '9':58+ return c - '0'59+ case c >= 'a' && c <= 'f':60+ return c - 'a' + 1061+ }62+ return 063+}@@ -11,14 +11,16 @@
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>21-{{end}}<h2>Recent activity</h2>14+{{end}}{{if .Connections}}<h2>Connect</h2>15+<p>Open this relay in an app. Each card is a way in; sign in to see the ones made for members.</p>16+{{range .Connections}}{{$c := asMap .}}<connect-card>17+<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>18+<p>{{str (index $c "about")}}</p>19+{{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>20+{{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>21+{{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}}22+</connect-card>23+{{end}}{{end}}<h2>Recent activity</h2>2224 {{if .Error}}{{template "alert" .Error}}{{else}}{{template "eventList" .}}{{end}}2325 </section>2426 {{end}}@@ -312,13 +312,13 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
312312 type connectionsBackend struct{ fakeBackend }313313 314314 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"}}, nil315+ if method == "connections" {316+ 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}"}}}}, nil317317 }318318 return nil, nil319319 }320320 321-func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) {321+func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) {322322 backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}323323 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})324324 if err != nil {@@ -335,16 +335,7 @@ func TestAccountPageOffersRelayListsAndHomeShowsConnections(t *testing.T) {
335335 recorder = httptest.NewRecorder()336336 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))337337 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")338+ 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>") {339+ t.Fatalf("home page did not show connect cards: %s", body[:min(400, len(body))])349340 }350341 }@@ -112,6 +112,8 @@ rpc-form output[data-error]{color:var(--alert)}
112112 113113 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}114114 115+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)}116+115117 /* json-view: structured rendering of any JSON payload. */116118 json-view{display:block;font:12px var(--mono)}117119 json-view>details>summary{cursor:pointer;color:var(--muted)}@@ -79,8 +79,8 @@ 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.82+ // Connections lists the ways to open this relay in client apps that the83+ // viewer may see, with link placeholders already resolved.8484 Connections []any8585 }8686 @@ -680,21 +680,7 @@ func identityNpub(identity string) string {
680680 681681 const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"682682 683-func encodeNpub(data []byte) string {684- fiveBits := convertBits(data)685- values := append(append([]byte{}, fiveBits...), 0, 0, 0, 0, 0, 0)686- polymod := bech32Polymod(append([]byte{3, 3, 3, 3, 0, 14, 16, 21, 2}, values...)) ^ 1687- checksum := make([]byte, 6)688- for i := range checksum {689- checksum[i] = byte(polymod >> uint(5*(5-i)) & 31)690- }691- var out strings.Builder692- out.WriteString("npub1")693- for _, value := range append(fiveBits, checksum...) {694- out.WriteByte(bech32Charset[value])695- }696- return out.String()697-}683+func encodeNpub(data []byte) string { return bech32("npub", data) }698684 699685 func convertBits(data []byte) []byte {700686 result := make([]byte, 0, 52)@@ -772,9 +758,9 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
772758 data.Feed = feed773759 }774760 }775- if tab == "home" && actor != "" {776- if result, err := a.backend.Query(request.Context(), "listconnections", nil, actor); err == nil {777- data.Connections = browseRows(result)761+ if tab == "home" {762+ if result, err := a.backend.Query(request.Context(), "connections", nil, actor); err == nil {763+ data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor})778764 }779765 }780766 if tab != "" {