Add Blossom package with core functionalities for blob storage and authorization
- Introduced the Blossom package, implementing essential features for handling blob storage, including upload, retrieval, and deletion of blobs. - Added authorization mechanisms for secure access to blob operations, validating authorization events based on Nostr standards. - Implemented various HTTP handlers for managing blob interactions, including GET, HEAD, PUT, and DELETE requests. - Developed utility functions for SHA256 hash calculations, MIME type detection, and range request handling. - Established a storage layer using Badger database for efficient blob data management and metadata storage. - Included placeholder implementations for media optimization and payment handling, setting the groundwork for future enhancements. - Documented the new functionalities and usage patterns in the codebase for better maintainability and understanding.
This commit is contained in:
250
pkg/blossom/utils.go
Normal file
250
pkg/blossom/utils.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/crypto/sha256"
|
||||
"next.orly.dev/pkg/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 {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user