utils.go raw
1 package blossom
2
3 import (
4 "net/http"
5 "path/filepath"
6 "regexp"
7 "strconv"
8 "strings"
9
10 "next.orly.dev/pkg/lol/errorf"
11 "github.com/minio/sha256-simd"
12 "next.orly.dev/pkg/nostr/encoders/hex"
13 )
14
15 const (
16 sha256HexLength = 64
17 maxRangeSize = 10 * 1024 * 1024 // 10MB max range request
18 )
19
20 var sha256Regex = regexp.MustCompile(`[a-fA-F0-9]{64}`)
21
22 // CalculateSHA256 calculates the SHA256 hash of data
23 func CalculateSHA256(data []byte) []byte {
24 hash := sha256.Sum256(data)
25 return hash[:]
26 }
27
28 // CalculateSHA256Hex calculates the SHA256 hash and returns it as hex string
29 func CalculateSHA256Hex(data []byte) string {
30 hash := sha256.Sum256(data)
31 return hex.Enc(hash[:])
32 }
33
34 // ExtractSHA256FromPath extracts SHA256 hash from URL path
35 // Supports both /<sha256> and /<sha256>.<ext> formats
36 func ExtractSHA256FromPath(path string) (sha256Hex string, ext string, err error) {
37 // Remove leading slash
38 path = strings.TrimPrefix(path, "/")
39
40 // Split by dot to separate hash and extension
41 parts := strings.SplitN(path, ".", 2)
42 sha256Hex = parts[0]
43
44 if len(parts) > 1 {
45 ext = "." + parts[1]
46 }
47
48 // Validate SHA256 hex format
49 if len(sha256Hex) != sha256HexLength {
50 err = errorf.E(
51 "invalid SHA256 length: expected %d, got %d",
52 sha256HexLength, len(sha256Hex),
53 )
54 return
55 }
56
57 if !sha256Regex.MatchString(sha256Hex) {
58 err = errorf.E("invalid SHA256 format: %s", sha256Hex)
59 return
60 }
61
62 return
63 }
64
65 // ExtractSHA256FromURL extracts SHA256 hash from a URL string
66 // Uses the last occurrence of a 64 char hex string (as per BUD-03)
67 func ExtractSHA256FromURL(urlStr string) (sha256Hex string, err error) {
68 // Find all 64-char hex strings
69 matches := sha256Regex.FindAllString(urlStr, -1)
70 if len(matches) == 0 {
71 err = errorf.E("no SHA256 hash found in URL: %s", urlStr)
72 return
73 }
74
75 // Return the last occurrence
76 sha256Hex = matches[len(matches)-1]
77 return
78 }
79
80 // GetMimeTypeFromExtension returns MIME type based on file extension
81 func GetMimeTypeFromExtension(ext string) string {
82 ext = strings.ToLower(ext)
83 mimeTypes := map[string]string{
84 ".pdf": "application/pdf",
85 ".png": "image/png",
86 ".jpg": "image/jpeg",
87 ".jpeg": "image/jpeg",
88 ".gif": "image/gif",
89 ".webp": "image/webp",
90 ".svg": "image/svg+xml",
91 ".mp4": "video/mp4",
92 ".webm": "video/webm",
93 ".mp3": "audio/mpeg",
94 ".wav": "audio/wav",
95 ".ogg": "audio/ogg",
96 ".txt": "text/plain",
97 ".html": "text/html",
98 ".css": "text/css",
99 ".js": "application/javascript",
100 ".json": "application/json",
101 ".xml": "application/xml",
102 ".zip": "application/zip",
103 ".tar": "application/x-tar",
104 ".gz": "application/gzip",
105 }
106
107 if mime, ok := mimeTypes[ext]; ok {
108 return mime
109 }
110 return "application/octet-stream"
111 }
112
113 // DetectMimeType detects MIME type from Content-Type header or file extension
114 func DetectMimeType(contentType string, ext string) string {
115 // First try Content-Type header
116 if contentType != "" {
117 // Remove any parameters (e.g., "text/plain; charset=utf-8")
118 parts := strings.Split(contentType, ";")
119 mime := strings.TrimSpace(parts[0])
120 if mime != "" && mime != "application/octet-stream" {
121 return mime
122 }
123 }
124
125 // Fall back to extension
126 if ext != "" {
127 return GetMimeTypeFromExtension(ext)
128 }
129
130 return "application/octet-stream"
131 }
132
133 // ParseRangeHeader parses HTTP Range header (RFC 7233)
134 // Returns start, end, and total length
135 func ParseRangeHeader(rangeHeader string, contentLength int64) (
136 start, end int64, valid bool, err error,
137 ) {
138 if rangeHeader == "" {
139 return 0, 0, false, nil
140 }
141
142 // Only support "bytes" unit
143 if !strings.HasPrefix(rangeHeader, "bytes=") {
144 return 0, 0, false, errorf.E("unsupported range unit")
145 }
146
147 rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
148 parts := strings.Split(rangeSpec, "-")
149
150 if len(parts) != 2 {
151 return 0, 0, false, errorf.E("invalid range format")
152 }
153
154 var startStr, endStr string
155 startStr = strings.TrimSpace(parts[0])
156 endStr = strings.TrimSpace(parts[1])
157
158 if startStr == "" && endStr == "" {
159 return 0, 0, false, errorf.E("invalid range: both start and end empty")
160 }
161
162 // Parse start
163 if startStr != "" {
164 if start, err = strconv.ParseInt(startStr, 10, 64); err != nil {
165 return 0, 0, false, errorf.E("invalid range start: %w", err)
166 }
167 if start < 0 {
168 return 0, 0, false, errorf.E("range start cannot be negative")
169 }
170 if start >= contentLength {
171 return 0, 0, false, errorf.E("range start exceeds content length")
172 }
173 } else {
174 // Suffix range: last N bytes
175 if end, err = strconv.ParseInt(endStr, 10, 64); err != nil {
176 return 0, 0, false, errorf.E("invalid range end: %w", err)
177 }
178 if end <= 0 {
179 return 0, 0, false, errorf.E("suffix range must be positive")
180 }
181 start = contentLength - end
182 if start < 0 {
183 start = 0
184 }
185 end = contentLength - 1
186 return start, end, true, nil
187 }
188
189 // Parse end
190 if endStr != "" {
191 if end, err = strconv.ParseInt(endStr, 10, 64); err != nil {
192 return 0, 0, false, errorf.E("invalid range end: %w", err)
193 }
194 if end < start {
195 return 0, 0, false, errorf.E("range end before start")
196 }
197 if end >= contentLength {
198 end = contentLength - 1
199 }
200 } else {
201 // Open-ended range: from start to end
202 end = contentLength - 1
203 }
204
205 // Validate range size
206 if end-start+1 > maxRangeSize {
207 return 0, 0, false, errorf.E("range too large: max %d bytes", maxRangeSize)
208 }
209
210 return start, end, true, nil
211 }
212
213 // WriteRangeResponse writes a partial content response (206)
214 func WriteRangeResponse(
215 w http.ResponseWriter, data []byte, start, end, totalLength int64,
216 ) {
217 w.Header().Set("Content-Range",
218 "bytes "+strconv.FormatInt(start, 10)+"-"+
219 strconv.FormatInt(end, 10)+"/"+
220 strconv.FormatInt(totalLength, 10))
221 w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10))
222 w.Header().Set("Accept-Ranges", "bytes")
223 w.WriteHeader(http.StatusPartialContent)
224 _, _ = w.Write(data[start : end+1])
225 }
226
227 // BuildBlobURL builds a blob URL with optional extension
228 func BuildBlobURL(baseURL, sha256Hex, ext string) string {
229 // Ensure baseURL ends with /
230 if !strings.HasSuffix(baseURL, "/") {
231 baseURL += "/"
232 }
233 url := baseURL + sha256Hex
234 if ext != "" {
235 url += ext
236 }
237 return url
238 }
239
240 // ValidateSHA256Hex validates that a string is a valid SHA256 hex string
241 func ValidateSHA256Hex(s string) bool {
242 if len(s) != sha256HexLength {
243 return false
244 }
245 _, err := hex.Dec(s)
246 return err == nil
247 }
248
249 // GetFileExtensionFromPath extracts file extension from a path
250 func GetFileExtensionFromPath(path string) string {
251 ext := filepath.Ext(path)
252 return ext
253 }
254
255 // GetExtensionFromMimeType returns file extension based on MIME type
256 func GetExtensionFromMimeType(mimeType string) string {
257 // Reverse lookup of GetMimeTypeFromExtension
258 mimeToExt := map[string]string{
259 "application/pdf": ".pdf",
260 "image/png": ".png",
261 "image/jpeg": ".jpg",
262 "image/gif": ".gif",
263 "image/webp": ".webp",
264 "image/svg+xml": ".svg",
265 "video/mp4": ".mp4",
266 "video/webm": ".webm",
267 "audio/mpeg": ".mp3",
268 "audio/wav": ".wav",
269 "audio/ogg": ".ogg",
270 "text/plain": ".txt",
271 "text/html": ".html",
272 "text/css": ".css",
273 "application/javascript": ".js",
274 "application/json": ".json",
275 "application/xml": ".xml",
276 "application/zip": ".zip",
277 "application/x-tar": ".tar",
278 "application/gzip": ".gz",
279 }
280
281 if ext, ok := mimeToExt[mimeType]; ok {
282 return ext
283 }
284 return "" // No extension for unknown MIME types
285 }
286
287