1package tinyclient23import (4 "encoding/binary"5 "encoding/hex"6 "net/http"7 "net/url"8 "regexp"9 "strconv"10 "strings"11)1213var hexID = regexp.MustCompile(`^[0-9a-f]{64}$`)1415// openLink handles web+nostr links registered through the installed app.16// It decodes the NIP-19 target and redirects to the page that shows it.17func (a *App) openLink(writer http.ResponseWriter, request *http.Request) {18 path, ok := nostrTargetPath(request.URL.Query().Get("target"))19 if !ok {20 http.Error(writer, "unsupported nostr link", http.StatusBadRequest)21 return22 }23 http.Redirect(writer, request, requestPrefix(request)+path, http.StatusSeeOther)24}2526// nostrTargetPath maps a nostr: or web+nostr: target to a relay page.27func nostrTargetPath(target string) (string, bool) {28 value := strings.TrimSpace(target)29 for _, prefix := range []string{"web+nostr:", "nostr:"} {30 if strings.HasPrefix(strings.ToLower(value), prefix) {31 value = value[len(prefix):]32 break33 }34 }35 value = strings.Trim(value, "/")36 if hexID.MatchString(value) {37 return "/e/" + value, true38 }39 hrp, data, ok := bech32Decode(strings.ToLower(value))40 if !ok {41 return "", false42 }43 switch hrp {44 case "npub":45 if len(data) != 32 {46 return "", false47 }48 return "/search?author=" + hex.EncodeToString(data), true49 case "note":50 if len(data) != 32 {51 return "", false52 }53 return "/e/" + hex.EncodeToString(data), true54 case "nprofile", "nevent", "naddr":55 fields := tlvFields(data)56 switch hrp {57 case "nprofile":58 if len(fields[0]) != 32 {59 return "", false60 }61 return "/search?author=" + hex.EncodeToString(fields[0]), true62 case "nevent":63 if len(fields[0]) != 32 {64 return "", false65 }66 return "/e/" + hex.EncodeToString(fields[0]), true67 default:68 if len(fields[2]) != 32 || len(fields[3]) != 4 {69 return "", false70 }71 kind := binary.BigEndian.Uint32(fields[3])72 return "/a/" + strconv.FormatUint(uint64(kind), 10) + ":" + hex.EncodeToString(fields[2]) + ":" + url.PathEscape(string(fields[0])), true73 }74 }75 return "", false76}7778// tlvFields returns the first value of each NIP-19 TLV type.79func tlvFields(data []byte) map[byte][]byte {80 fields := map[byte][]byte{}81 for len(data) >= 2 {82 kind, length := data[0], int(data[1])83 if len(data) < 2+length {84 break85 }86 if _, seen := fields[kind]; !seen {87 fields[kind] = data[2 : 2+length]88 }89 data = data[2+length:]90 }91 return fields92}9394// bech32Decode verifies a bech32 string and returns its 8-bit payload.95func bech32Decode(value string) (string, []byte, bool) {96 separator := strings.LastIndexByte(value, '1')97 if separator < 1 || separator+7 > len(value) || len(value) > 5000 {98 return "", nil, false99 }100 hrp, encoded := value[:separator], value[separator+1:]101 values := make([]byte, len(encoded))102 for i, char := range encoded {103 position := strings.IndexRune(bech32Charset, char)104 if position < 0 {105 return "", nil, false106 }107 values[i] = byte(position)108 }109 if bech32Polymod(append(bech32Expand(hrp), values...)) != 1 {110 return "", nil, false111 }112 data := values[:len(values)-6]113 accumulator, bits := 0, 0114 var result []byte115 for _, v := range data {116 accumulator = accumulator<<5 | int(v)117 bits += 5118 if bits >= 8 {119 bits -= 8120 result = append(result, byte(accumulator>>bits&255))121 }122 }123 if bits >= 5 || accumulator<<(8-bits)&255 != 0 {124 return "", nil, false125 }126 return hrp, result, true127}128129func bech32Expand(hrp string) []byte {130 expanded := make([]byte, 0, len(hrp)*2+1)131 for _, c := range hrp {132 expanded = append(expanded, byte(c)>>5)133 }134 expanded = append(expanded, 0)135 for _, c := range hrp {136 expanded = append(expanded, byte(c)&31)137 }138 return expanded139}