1package tinyclient23import (4 "encoding/binary"5 "encoding/hex"6 "net/url"7 "regexp"8 "strings"9)1011func identityNpub(identity string) string {12 decoded, err := hex.DecodeString(identity)13 if err != nil || len(decoded) != 32 {14 return identity15 }16 return bech32("npub", decoded)17}1819const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"2021// bech32 encodes data with the given human readable part.22func bech32(hrp string, data []byte) string {23 fiveBits := convertBits(data)24 expanded := make([]byte, 0, len(hrp)*2+1)25 for _, c := range hrp {26 expanded = append(expanded, byte(c)>>5)27 }28 expanded = append(expanded, 0)29 for _, c := range hrp {30 expanded = append(expanded, byte(c)&31)31 }32 values := append(append(append([]byte{}, expanded...), fiveBits...), 0, 0, 0, 0, 0, 0)33 polymod := bech32Polymod(values) ^ 134 checksum := make([]byte, 6)35 for i := range checksum {36 checksum[i] = byte(polymod >> uint(5*(5-i)) & 31)37 }38 var out strings.Builder39 out.WriteString(hrp + "1")40 for _, value := range append(fiveBits, checksum...) {41 out.WriteByte(bech32Charset[value])42 }43 return out.String()44}4546func convertBits(data []byte) []byte {47 result := make([]byte, 0, 52)48 accumulator, bits := 0, 049 for _, value := range data {50 accumulator = (accumulator << 8) | int(value)51 bits += 852 for bits >= 5 {53 bits -= 554 result = append(result, byte(accumulator>>bits&31))55 }56 }57 if bits > 0 {58 result = append(result, byte(accumulator<<(5-bits)&31))59 }60 return result61}6263func bech32Polymod(values []byte) uint64 {64 generators := [...]uint64{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}65 checksum := uint64(1)66 for _, value := range values {67 top := checksum >> 2568 checksum = (checksum&0x1ffffff)<<5 ^ uint64(value)69 for i, generator := range generators {70 if top>>uint(i)&1 != 0 {71 checksum ^= generator72 }73 }74 }75 return checksum76}7778func tlv(kind byte, value []byte) []byte {79 return append([]byte{kind, byte(len(value))}, value...)80}8182// nprofile encodes a NIP-19 profile pointer with one relay hint.83func nprofile(pubkeyHex, relay string) string {84 pubkey, err := hex.DecodeString(pubkeyHex)85 if err != nil || len(pubkey) != 32 {86 return ""87 }88 data := tlv(0, pubkey)89 if relay != "" {90 data = append(data, tlv(1, []byte(relay))...)91 }92 return bech32("nprofile", data)93}9495// naddr encodes a NIP-19 address pointer for a replaceable event.96func naddr(kind uint32, pubkeyHex, identifier, relay string) string {97 pubkey, err := hex.DecodeString(pubkeyHex)98 if err != nil || len(pubkey) != 32 {99 return ""100 }101 data := tlv(0, []byte(identifier))102 if relay != "" {103 data = append(data, tlv(1, []byte(relay))...)104 }105 data = append(data, tlv(2, pubkey)...)106 kindBytes := make([]byte, 4)107 binary.BigEndian.PutUint32(kindBytes, kind)108 data = append(data, tlv(3, kindBytes)...)109 return bech32("naddr", data)110}111112var placeholder = regexp.MustCompile(`\{([a-z]+):([a-z]+)(?:\|(enc))?\}`)113114// connectionValues resolves the placeholders connection templates use:115// relay:url, relay:web, relay:host, relay:domain, relay:naddr, owner:npub,116// owner:nprofile, user:npub and user:nprofile. The relay's group address is117// its NIP-29 top-level group, published under the relay identity.118func connectionValues(data PageData) map[string]string {119 values := map[string]string{}120 web := strings.TrimRight(data.URL, "/")121 values["relay:web"] = web122 values["relay:url"] = wsURL(web)123 if parsed, err := url.Parse(web); err == nil {124 values["relay:host"] = parsed.Host125 values["relay:domain"] = parsed.Hostname()126 }127 if data.Identity != "" {128 values["relay:naddr"] = naddr(39000, data.Identity, "_", values["relay:url"])129 }130 if data.Policy.Owner != "" {131 values["owner:npub"] = identityNpub(data.Policy.Owner)132 values["owner:nprofile"] = nprofile(data.Policy.Owner, values["relay:url"])133 }134 if data.Actor != "" {135 values["user:npub"] = identityNpub(data.Actor)136 values["user:nprofile"] = nprofile(data.Actor, values["relay:url"])137 }138 return values139}140141// fillPlaceholders substitutes known values, leaving input placeholders for142// the page script. It reports false when a needed value is missing, such as143// a user value for a guest, so the link can be left out.144func fillPlaceholders(text string, values map[string]string) (string, bool) {145 complete := true146 out := placeholder.ReplaceAllStringFunc(text, func(match string) string {147 parts := placeholder.FindStringSubmatch(match)148 if parts[1] == "input" {149 return match150 }151 value, ok := values[parts[1]+":"+parts[2]]152 if !ok || value == "" {153 complete = false154 return ""155 }156 if parts[3] == "enc" {157 return url.QueryEscape(value)158 }159 return value160 })161 return out, complete162}163164// expandConnections turns the daemon's connection rows into ready-to-render165// maps with placeholders resolved. Links that need a signed-in user are166// dropped for guests; the card keeps a note that signing in reveals them.167func expandConnections(rows []any, data PageData) []any {168 values := connectionValues(data)169 out := make([]any, 0, len(rows))170 for _, row := range rows {171 item := valueMap(row)172 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"]}173 if card["title"] == "" {174 card["title"] = card["name"]175 }176 if qr, ok := fillPlaceholders(plainString(item["qr"]), values); ok && qr != "" {177 card["qr"] = qr178 }179 links := []any{}180 hidden := 0181 for _, link := range browseRows(item["links"]) {182 l := valueMap(link)183 field := func(name string) string {184 if value := plainString(l[name]); value != "" {185 return value186 }187 return plainString(l[strings.ToUpper(name[:1])+name[1:]])188 }189 href, okHref := fillPlaceholders(field("href"), values)190 copyText, okCopy := fillPlaceholders(field("copy"), values)191 if !okHref || !okCopy {192 hidden++193 continue194 }195 links = append(links, map[string]any{"label": field("label"), "href": href, "copy": copyText})196 }197 card["links"], card["hidden"] = links, hidden198 out = append(out, card)199 }200 return out201}