tinyrelayfile
tinyrelay/internal/blob/blossom.go/

internal/blob/blossom.go raw
1package blob
2
3import (
4 "errors"
5 "fmt"
6 "net/url"
7 "regexp"
8 "sort"
9 "strconv"
10 "strings"
11
12 "github.com/FelineStateMachine/tinyrelay/internal/event"
13)
14
15// BlossomURI is the portable BUD-10 representation of a blob reference.
16type BlossomURI struct {
17 Hash string
18 Extension string
19 Servers []string
20 Author string
21 Authors []string
22 Size int64
23}
24
25var hashInURL = regexp.MustCompile(`(?i)([0-9a-f]{64})(?:\.[a-z0-9]{1,16})?(?:$|[^0-9a-f])`)
26
27// ParseBlossomURI parses blossom:<sha256>[.extension] and its optional
28// server, author, and size hints.
29func ParseBlossomURI(raw string) (BlossomURI, error) {
30 if !strings.HasPrefix(strings.ToLower(raw), "blossom:") {
31 return BlossomURI{}, errors.New("invalid: Blossom URI scheme")
32 }
33 body := raw[len("blossom:"):]
34 parts := strings.SplitN(body, "?", 2)
35 path := parts[0]
36 base := path
37 if dot := strings.IndexByte(path, '.'); dot >= 0 {
38 base = path[:dot]
39 }
40 if !shaPattern.MatchString(strings.ToLower(base)) {
41 return BlossomURI{}, errors.New("invalid: Blossom URI hash")
42 }
43 result := BlossomURI{Hash: strings.ToLower(base)}
44 if len(path) > len(base) {
45 result.Extension = strings.ToLower(path[len(base)+1:])
46 if !regexp.MustCompile(`^[a-z0-9]{1,16}$`).MatchString(result.Extension) {
47 return BlossomURI{}, errors.New("invalid: Blossom URI extension")
48 }
49 } else {
50 result.Extension = "bin"
51 }
52 if len(parts) == 1 {
53 return result, nil
54 }
55 values, err := url.ParseQuery(parts[1])
56 if err != nil {
57 return BlossomURI{}, fmt.Errorf("invalid: Blossom URI query: %w", err)
58 }
59 for _, server := range values["xs"] {
60 if !validServerHint(server) {
61 return BlossomURI{}, errors.New("invalid: Blossom server hint")
62 }
63 result.Servers = append(result.Servers, strings.TrimRight(strings.TrimSpace(server), "/"))
64 }
65 for _, author := range values["as"] {
66 if !shaPattern.MatchString(strings.ToLower(author)) {
67 return BlossomURI{}, errors.New("invalid: Blossom author hint")
68 }
69 result.Authors = append(result.Authors, strings.ToLower(author))
70 }
71 if len(result.Authors) > 0 {
72 result.Author = result.Authors[0]
73 }
74 if rawSize := values.Get("sz"); rawSize != "" {
75 result.Size, err = strconv.ParseInt(rawSize, 10, 64)
76 if err != nil || result.Size <= 0 {
77 return BlossomURI{}, errors.New("invalid: Blossom URI size")
78 }
79 }
80 return result, nil
81}
82
83func validServerHint(raw string) bool {
84 value := strings.TrimSpace(raw)
85 if value == "" || strings.ContainsAny(value, " \t\r\n@") {
86 return false
87 }
88 parsed, err := url.Parse(normalizeServerHint(value))
89 return err == nil && parsed.Hostname() != "" && parsed.User == nil && parsed.Path == ""
90}
91
92func normalizeServerHint(raw string) string {
93 value := strings.TrimRight(strings.TrimSpace(raw), "/")
94 if !strings.Contains(value, "://") {
95 return "https://" + value
96 }
97 return value
98}
99
100// String returns a canonical BUD-10 URI with stable query ordering.
101func (u BlossomURI) String() string {
102 path := "blossom:" + u.Hash
103 if u.Extension != "" {
104 path += "." + u.Extension
105 }
106 values := url.Values{}
107 for _, server := range u.Servers {
108 values.Add("xs", strings.TrimPrefix(strings.TrimPrefix(server, "https://"), "http://"))
109 }
110 if len(u.Authors) > 0 || u.Author != "" {
111 if len(u.Authors) == 0 {
112 values.Set("as", u.Author)
113 } else {
114 for _, author := range u.Authors {
115 values.Add("as", author)
116 }
117 }
118 }
119 if u.Size > 0 {
120 values.Set("sz", strconv.FormatInt(u.Size, 10))
121 }
122 if encoded := values.Encode(); encoded != "" {
123 path += "?" + encoded
124 }
125 return path
126}
127
128// BlossomServerList extracts the ordered BUD-03 server list from kind 10063.
129func BlossomServerList(e event.Event) ([]string, error) {
130 if e.Kind != 10063 {
131 return nil, errors.New("invalid: event is not a Blossom server list")
132 }
133 servers := make([]string, 0)
134 seen := make(map[string]struct{})
135 for _, tag := range e.Tags {
136 if len(tag) < 2 || tag[0] != "server" {
137 continue
138 }
139 parsed, err := url.Parse(strings.TrimSpace(tag[1]))
140 if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
141 return nil, errors.New("invalid: Blossom server list URL")
142 }
143 server := strings.TrimRight(parsed.String(), "/")
144 if _, ok := seen[server]; ok {
145 continue
146 }
147 seen[server] = struct{}{}
148 servers = append(servers, server)
149 }
150 if len(servers) == 0 {
151 return nil, errors.New("invalid: Blossom server list is empty")
152 }
153 return servers, nil
154}
155
156// BlobHashFromURL returns the last 64-hex component in a URL, as required by
157// BUD-03 and NIP-B7 for recovery from a broken original URL.
158func BlobHashFromURL(raw string) string {
159 matches := hashInURL.FindAllStringSubmatch(raw, -1)
160 if len(matches) == 0 {
161 return ""
162 }
163 return strings.ToLower(matches[len(matches)-1][1])
164}
165
166// FallbackURLs produces ordered server URLs for a recovered blob.
167func FallbackURLs(servers []string, hash, extension string) []string {
168 if !shaPattern.MatchString(strings.ToLower(hash)) {
169 return nil
170 }
171 if extension != "" && !strings.HasPrefix(extension, ".") {
172 extension = "." + extension
173 }
174 result := make([]string, 0, len(servers))
175 for _, server := range servers {
176 raw := strings.TrimRight(strings.TrimSpace(server), "/")
177 if strings.Contains(raw, "://") {
178 result = append(result, raw+"/"+strings.ToLower(hash)+extension)
179 } else {
180 result = append(result, "https://"+raw+"/"+strings.ToLower(hash)+extension)
181 result = append(result, "http://"+raw+"/"+strings.ToLower(hash)+extension)
182 }
183 }
184 return result
185}
186
187// SortedServerList is useful for operator display without changing the
188// original ordered list used for client failover.
189func SortedServerList(servers []string) []string {
190 result := append([]string(nil), servers...)
191 sort.Strings(result)
192 return result
193}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs