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