You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
286 lines
7.4 KiB
286 lines
7.4 KiB
package blossom |
|
|
|
import ( |
|
"net/http" |
|
"path/filepath" |
|
"regexp" |
|
"strconv" |
|
"strings" |
|
|
|
"lol.mleku.dev/errorf" |
|
"github.com/minio/sha256-simd" |
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
|
) |
|
|
|
const ( |
|
sha256HexLength = 64 |
|
maxRangeSize = 10 * 1024 * 1024 // 10MB max range request |
|
) |
|
|
|
var sha256Regex = regexp.MustCompile(`[a-fA-F0-9]{64}`) |
|
|
|
// CalculateSHA256 calculates the SHA256 hash of data |
|
func CalculateSHA256(data []byte) []byte { |
|
hash := sha256.Sum256(data) |
|
return hash[:] |
|
} |
|
|
|
// CalculateSHA256Hex calculates the SHA256 hash and returns it as hex string |
|
func CalculateSHA256Hex(data []byte) string { |
|
hash := sha256.Sum256(data) |
|
return hex.Enc(hash[:]) |
|
} |
|
|
|
// ExtractSHA256FromPath extracts SHA256 hash from URL path |
|
// Supports both /<sha256> and /<sha256>.<ext> formats |
|
func ExtractSHA256FromPath(path string) (sha256Hex string, ext string, err error) { |
|
// Remove leading slash |
|
path = strings.TrimPrefix(path, "/") |
|
|
|
// Split by dot to separate hash and extension |
|
parts := strings.SplitN(path, ".", 2) |
|
sha256Hex = parts[0] |
|
|
|
if len(parts) > 1 { |
|
ext = "." + parts[1] |
|
} |
|
|
|
// Validate SHA256 hex format |
|
if len(sha256Hex) != sha256HexLength { |
|
err = errorf.E( |
|
"invalid SHA256 length: expected %d, got %d", |
|
sha256HexLength, len(sha256Hex), |
|
) |
|
return |
|
} |
|
|
|
if !sha256Regex.MatchString(sha256Hex) { |
|
err = errorf.E("invalid SHA256 format: %s", sha256Hex) |
|
return |
|
} |
|
|
|
return |
|
} |
|
|
|
// ExtractSHA256FromURL extracts SHA256 hash from a URL string |
|
// Uses the last occurrence of a 64 char hex string (as per BUD-03) |
|
func ExtractSHA256FromURL(urlStr string) (sha256Hex string, err error) { |
|
// Find all 64-char hex strings |
|
matches := sha256Regex.FindAllString(urlStr, -1) |
|
if len(matches) == 0 { |
|
err = errorf.E("no SHA256 hash found in URL: %s", urlStr) |
|
return |
|
} |
|
|
|
// Return the last occurrence |
|
sha256Hex = matches[len(matches)-1] |
|
return |
|
} |
|
|
|
// GetMimeTypeFromExtension returns MIME type based on file extension |
|
func GetMimeTypeFromExtension(ext string) string { |
|
ext = strings.ToLower(ext) |
|
mimeTypes := map[string]string{ |
|
".pdf": "application/pdf", |
|
".png": "image/png", |
|
".jpg": "image/jpeg", |
|
".jpeg": "image/jpeg", |
|
".gif": "image/gif", |
|
".webp": "image/webp", |
|
".svg": "image/svg+xml", |
|
".mp4": "video/mp4", |
|
".webm": "video/webm", |
|
".mp3": "audio/mpeg", |
|
".wav": "audio/wav", |
|
".ogg": "audio/ogg", |
|
".txt": "text/plain", |
|
".html": "text/html", |
|
".css": "text/css", |
|
".js": "application/javascript", |
|
".json": "application/json", |
|
".xml": "application/xml", |
|
".zip": "application/zip", |
|
".tar": "application/x-tar", |
|
".gz": "application/gzip", |
|
} |
|
|
|
if mime, ok := mimeTypes[ext]; ok { |
|
return mime |
|
} |
|
return "application/octet-stream" |
|
} |
|
|
|
// DetectMimeType detects MIME type from Content-Type header or file extension |
|
func DetectMimeType(contentType string, ext string) string { |
|
// First try Content-Type header |
|
if contentType != "" { |
|
// Remove any parameters (e.g., "text/plain; charset=utf-8") |
|
parts := strings.Split(contentType, ";") |
|
mime := strings.TrimSpace(parts[0]) |
|
if mime != "" && mime != "application/octet-stream" { |
|
return mime |
|
} |
|
} |
|
|
|
// Fall back to extension |
|
if ext != "" { |
|
return GetMimeTypeFromExtension(ext) |
|
} |
|
|
|
return "application/octet-stream" |
|
} |
|
|
|
// ParseRangeHeader parses HTTP Range header (RFC 7233) |
|
// Returns start, end, and total length |
|
func ParseRangeHeader(rangeHeader string, contentLength int64) ( |
|
start, end int64, valid bool, err error, |
|
) { |
|
if rangeHeader == "" { |
|
return 0, 0, false, nil |
|
} |
|
|
|
// Only support "bytes" unit |
|
if !strings.HasPrefix(rangeHeader, "bytes=") { |
|
return 0, 0, false, errorf.E("unsupported range unit") |
|
} |
|
|
|
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=") |
|
parts := strings.Split(rangeSpec, "-") |
|
|
|
if len(parts) != 2 { |
|
return 0, 0, false, errorf.E("invalid range format") |
|
} |
|
|
|
var startStr, endStr string |
|
startStr = strings.TrimSpace(parts[0]) |
|
endStr = strings.TrimSpace(parts[1]) |
|
|
|
if startStr == "" && endStr == "" { |
|
return 0, 0, false, errorf.E("invalid range: both start and end empty") |
|
} |
|
|
|
// Parse start |
|
if startStr != "" { |
|
if start, err = strconv.ParseInt(startStr, 10, 64); err != nil { |
|
return 0, 0, false, errorf.E("invalid range start: %w", err) |
|
} |
|
if start < 0 { |
|
return 0, 0, false, errorf.E("range start cannot be negative") |
|
} |
|
if start >= contentLength { |
|
return 0, 0, false, errorf.E("range start exceeds content length") |
|
} |
|
} else { |
|
// Suffix range: last N bytes |
|
if end, err = strconv.ParseInt(endStr, 10, 64); err != nil { |
|
return 0, 0, false, errorf.E("invalid range end: %w", err) |
|
} |
|
if end <= 0 { |
|
return 0, 0, false, errorf.E("suffix range must be positive") |
|
} |
|
start = contentLength - end |
|
if start < 0 { |
|
start = 0 |
|
} |
|
end = contentLength - 1 |
|
return start, end, true, nil |
|
} |
|
|
|
// Parse end |
|
if endStr != "" { |
|
if end, err = strconv.ParseInt(endStr, 10, 64); err != nil { |
|
return 0, 0, false, errorf.E("invalid range end: %w", err) |
|
} |
|
if end < start { |
|
return 0, 0, false, errorf.E("range end before start") |
|
} |
|
if end >= contentLength { |
|
end = contentLength - 1 |
|
} |
|
} else { |
|
// Open-ended range: from start to end |
|
end = contentLength - 1 |
|
} |
|
|
|
// Validate range size |
|
if end-start+1 > maxRangeSize { |
|
return 0, 0, false, errorf.E("range too large: max %d bytes", maxRangeSize) |
|
} |
|
|
|
return start, end, true, nil |
|
} |
|
|
|
// WriteRangeResponse writes a partial content response (206) |
|
func WriteRangeResponse( |
|
w http.ResponseWriter, data []byte, start, end, totalLength int64, |
|
) { |
|
w.Header().Set("Content-Range", |
|
"bytes "+strconv.FormatInt(start, 10)+"-"+ |
|
strconv.FormatInt(end, 10)+"/"+ |
|
strconv.FormatInt(totalLength, 10)) |
|
w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10)) |
|
w.Header().Set("Accept-Ranges", "bytes") |
|
w.WriteHeader(http.StatusPartialContent) |
|
_, _ = w.Write(data[start : end+1]) |
|
} |
|
|
|
// BuildBlobURL builds a blob URL with optional extension |
|
func BuildBlobURL(baseURL, sha256Hex, ext string) string { |
|
// Ensure baseURL ends with / |
|
if !strings.HasSuffix(baseURL, "/") { |
|
baseURL += "/" |
|
} |
|
url := baseURL + sha256Hex |
|
if ext != "" { |
|
url += ext |
|
} |
|
return url |
|
} |
|
|
|
// ValidateSHA256Hex validates that a string is a valid SHA256 hex string |
|
func ValidateSHA256Hex(s string) bool { |
|
if len(s) != sha256HexLength { |
|
return false |
|
} |
|
_, err := hex.Dec(s) |
|
return err == nil |
|
} |
|
|
|
// GetFileExtensionFromPath extracts file extension from a path |
|
func GetFileExtensionFromPath(path string) string { |
|
ext := filepath.Ext(path) |
|
return ext |
|
} |
|
|
|
// GetExtensionFromMimeType returns file extension based on MIME type |
|
func GetExtensionFromMimeType(mimeType string) string { |
|
// Reverse lookup of GetMimeTypeFromExtension |
|
mimeToExt := map[string]string{ |
|
"application/pdf": ".pdf", |
|
"image/png": ".png", |
|
"image/jpeg": ".jpg", |
|
"image/gif": ".gif", |
|
"image/webp": ".webp", |
|
"image/svg+xml": ".svg", |
|
"video/mp4": ".mp4", |
|
"video/webm": ".webm", |
|
"audio/mpeg": ".mp3", |
|
"audio/wav": ".wav", |
|
"audio/ogg": ".ogg", |
|
"text/plain": ".txt", |
|
"text/html": ".html", |
|
"text/css": ".css", |
|
"application/javascript": ".js", |
|
"application/json": ".json", |
|
"application/xml": ".xml", |
|
"application/zip": ".zip", |
|
"application/x-tar": ".tar", |
|
"application/gzip": ".gz", |
|
} |
|
|
|
if ext, ok := mimeToExt[mimeType]; ok { |
|
return ext |
|
} |
|
return "" // No extension for unknown MIME types |
|
} |
|
|
|
|