| 1 | package blob |
| 2 | |
| 3 | import ( |
| 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. |
| 16 | type BlossomURI struct { |
| 17 | Hash string |
| 18 | Extension string |
| 19 | Servers []string |
| 20 | Author string |
| 21 | Authors []string |
| 22 | Size int64 |
| 23 | } |
| 24 | |
| 25 | var 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. |
| 29 | func 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 | |
| 83 | func 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 | |
| 92 | func 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. |
| 101 | func (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. |
| 129 | func 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. |
| 158 | func 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. |
| 167 | func 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. |
| 189 | func SortedServerList(servers []string) []string { |
| 190 | result := append([]string(nil), servers...) |
| 191 | sort.Strings(result) |
| 192 | return result |
| 193 | } |