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:
2025-11-02 21:09:18 +00:00
parent 8d131b6137
commit 9082481129
8 changed files with 1998 additions and 0 deletions

294
pkg/blossom/auth.go Normal file
View File

@@ -0,0 +1,294 @@
package blossom
import (
"encoding/base64"
"net/http"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/ints"
)
const (
// BlossomAuthKind is the Nostr event kind for Blossom authorization events (BUD-01)
BlossomAuthKind = 24242
// AuthorizationHeader is the HTTP header name for authorization
AuthorizationHeader = "Authorization"
// NostrAuthPrefix is the prefix for Nostr authorization scheme
NostrAuthPrefix = "Nostr"
)
// AuthEvent represents a validated authorization event
type AuthEvent struct {
Event *event.E
Pubkey []byte
Verb string
Expires int64
}
// ExtractAuthEvent extracts and parses a kind 24242 authorization event from the Authorization header
func ExtractAuthEvent(r *http.Request) (ev *event.E, err error) {
authHeader := r.Header.Get(AuthorizationHeader)
if authHeader == "" {
err = errorf.E("missing Authorization header")
return
}
// Parse "Nostr <base64>" format
if !strings.HasPrefix(authHeader, NostrAuthPrefix+" ") {
err = errorf.E("invalid Authorization scheme, expected 'Nostr'")
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
err = errorf.E("invalid Authorization header format")
return
}
var evb []byte
if evb, err = base64.StdEncoding.DecodeString(parts[1]); chk.E(err) {
return
}
ev = event.New()
var rem []byte
if rem, err = ev.Unmarshal(evb); chk.E(err) {
return
}
if len(rem) > 0 {
err = errorf.E("unexpected trailing data in auth event")
return
}
return
}
// ValidateAuthEvent validates a kind 24242 authorization event according to BUD-01
func ValidateAuthEvent(
r *http.Request, verb string, sha256Hash []byte,
) (authEv *AuthEvent, err error) {
var ev *event.E
if ev, err = ExtractAuthEvent(r); chk.E(err) {
return
}
// 1. The kind must be 24242
if ev.Kind != BlossomAuthKind {
err = errorf.E(
"invalid kind %d in authorization event, require %d",
ev.Kind, BlossomAuthKind,
)
return
}
// 2. created_at must be in the past
now := time.Now().Unix()
if ev.CreatedAt > now {
err = errorf.E(
"authorization event created_at %d is in the future (now: %d)",
ev.CreatedAt, now,
)
return
}
// 3. Check expiration tag (must be set and in the future)
expTags := ev.Tags.GetAll([]byte("expiration"))
if len(expTags) == 0 {
err = errorf.E("authorization event missing expiration tag")
return
}
if len(expTags) > 1 {
err = errorf.E("authorization event has multiple expiration tags")
return
}
expInt := ints.New(0)
var rem []byte
if rem, err = expInt.Unmarshal(expTags[0].Value()); chk.E(err) {
return
}
if len(rem) > 0 {
err = errorf.E("unexpected trailing data in expiration tag")
return
}
expiration := expInt.Int64()
if expiration <= now {
err = errorf.E(
"authorization event expired: expiration %d <= now %d",
expiration, now,
)
return
}
// 4. The t tag must have a verb matching the intended action
tTags := ev.Tags.GetAll([]byte("t"))
if len(tTags) == 0 {
err = errorf.E("authorization event missing 't' tag")
return
}
if len(tTags) > 1 {
err = errorf.E("authorization event has multiple 't' tags")
return
}
eventVerb := string(tTags[0].Value())
if eventVerb != verb {
err = errorf.E(
"authorization event verb '%s' does not match required verb '%s'",
eventVerb, verb,
)
return
}
// 5. If sha256Hash is provided, verify at least one x tag matches
if sha256Hash != nil && len(sha256Hash) > 0 {
sha256Hex := hex.Enc(sha256Hash)
xTags := ev.Tags.GetAll([]byte("x"))
if len(xTags) == 0 {
err = errorf.E(
"authorization event missing 'x' tag for SHA256 hash %s",
sha256Hex,
)
return
}
found := false
for _, xTag := range xTags {
if string(xTag.Value()) == sha256Hex {
found = true
break
}
}
if !found {
err = errorf.E(
"authorization event has no 'x' tag matching SHA256 hash %s",
sha256Hex,
)
return
}
}
// 6. Verify event signature
var valid bool
if valid, err = ev.Verify(); chk.E(err) {
return
}
if !valid {
err = errorf.E("authorization event signature verification failed")
return
}
authEv = &AuthEvent{
Event: ev,
Pubkey: ev.Pubkey,
Verb: eventVerb,
Expires: expiration,
}
return
}
// ValidateAuthEventOptional validates authorization but returns nil if no auth header is present
// This is used for endpoints where authorization is optional
func ValidateAuthEventOptional(
r *http.Request, verb string, sha256Hash []byte,
) (authEv *AuthEvent, err error) {
authHeader := r.Header.Get(AuthorizationHeader)
if authHeader == "" {
// No authorization provided, but that's OK for optional endpoints
return nil, nil
}
return ValidateAuthEvent(r, verb, sha256Hash)
}
// ValidateAuthEventForGet validates authorization for GET requests (BUD-01)
// GET requests may have either:
// - A server tag matching the server URL
// - At least one x tag matching the blob hash
func ValidateAuthEventForGet(
r *http.Request, serverURL string, sha256Hash []byte,
) (authEv *AuthEvent, err error) {
var ev *event.E
if ev, err = ExtractAuthEvent(r); chk.E(err) {
return
}
// Basic validation
if authEv, err = ValidateAuthEvent(r, "get", sha256Hash); chk.E(err) {
return
}
// For GET requests, check server tag or x tag
serverTags := ev.Tags.GetAll([]byte("server"))
xTags := ev.Tags.GetAll([]byte("x"))
// If server tag exists, verify it matches
if len(serverTags) > 0 {
serverTagValue := string(serverTags[0].Value())
if !strings.HasPrefix(serverURL, serverTagValue) {
err = errorf.E(
"server tag '%s' does not match server URL '%s'",
serverTagValue, serverURL,
)
return
}
return
}
// Otherwise, verify at least one x tag matches the hash
if sha256Hash != nil && len(sha256Hash) > 0 {
sha256Hex := hex.Enc(sha256Hash)
found := false
for _, xTag := range xTags {
if string(xTag.Value()) == sha256Hex {
found = true
break
}
}
if !found {
err = errorf.E(
"no 'x' tag matching SHA256 hash %s",
sha256Hex,
)
return
}
} else if len(xTags) == 0 {
err = errorf.E(
"authorization event must have either 'server' tag or 'x' tag",
)
return
}
return
}
// GetPubkeyFromRequest extracts pubkey from Authorization header if present
func GetPubkeyFromRequest(r *http.Request) (pubkey []byte, err error) {
authHeader := r.Header.Get(AuthorizationHeader)
if authHeader == "" {
return nil, nil
}
authEv, err := ValidateAuthEventOptional(r, "", nil)
if err != nil {
// If validation fails, return empty pubkey but no error
// This allows endpoints to work without auth
return nil, nil
}
if authEv != nil {
return authEv.Pubkey, nil
}
return nil, nil
}

65
pkg/blossom/blob.go Normal file
View File

@@ -0,0 +1,65 @@
package blossom
import (
"encoding/json"
"time"
)
// BlobDescriptor represents a blob descriptor as defined in BUD-02
type BlobDescriptor struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Type string `json:"type"`
Uploaded int64 `json:"uploaded"`
NIP94 [][]string `json:"nip94,omitempty"`
}
// BlobMetadata stores metadata about a blob in the database
type BlobMetadata struct {
Pubkey []byte `json:"pubkey"`
MimeType string `json:"mime_type"`
Uploaded int64 `json:"uploaded"`
Size int64 `json:"size"`
}
// NewBlobDescriptor creates a new blob descriptor
func NewBlobDescriptor(
url, sha256 string, size int64, mimeType string, uploaded int64,
) *BlobDescriptor {
if mimeType == "" {
mimeType = "application/octet-stream"
}
return &BlobDescriptor{
URL: url,
SHA256: sha256,
Size: size,
Type: mimeType,
Uploaded: uploaded,
}
}
// NewBlobMetadata creates a new blob metadata struct
func NewBlobMetadata(pubkey []byte, mimeType string, size int64) *BlobMetadata {
if mimeType == "" {
mimeType = "application/octet-stream"
}
return &BlobMetadata{
Pubkey: pubkey,
MimeType: mimeType,
Uploaded: time.Now().Unix(),
Size: size,
}
}
// Serialize serializes blob metadata to JSON
func (bm *BlobMetadata) Serialize() (data []byte, err error) {
return json.Marshal(bm)
}
// DeserializeBlobMetadata deserializes blob metadata from JSON
func DeserializeBlobMetadata(data []byte) (bm *BlobMetadata, err error) {
bm = &BlobMetadata{}
err = json.Unmarshal(data, bm)
return
}

783
pkg/blossom/handlers.go Normal file
View File

@@ -0,0 +1,783 @@
package blossom
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/utils"
)
// handleGetBlob handles GET /<sha256> requests (BUD-01)
func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
// Extract SHA256 and extension
sha256Hex, ext, err := ExtractSHA256FromPath(path)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, err.Error())
return
}
// Convert hex to bytes
sha256Hash, err := hex.Dec(sha256Hex)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
return
}
// Check if blob exists
exists, err := s.storage.HasBlob(sha256Hash)
if err != nil {
log.E.F("error checking blob existence: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
if !exists {
s.setErrorResponse(w, http.StatusNotFound, "blob not found")
return
}
// Get blob metadata
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
if err != nil {
log.E.F("error getting blob metadata: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Optional authorization check (BUD-01)
if s.requireAuth {
authEv, err := ValidateAuthEventForGet(r, s.baseURL, sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
if authEv == nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
}
// Get blob data
blobData, _, err := s.storage.GetBlob(sha256Hash)
if err != nil {
log.E.F("error getting blob: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Set headers
mimeType := DetectMimeType(metadata.MimeType, ext)
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(blobData)), 10))
w.Header().Set("Accept-Ranges", "bytes")
// Handle range requests (RFC 7233)
rangeHeader := r.Header.Get("Range")
if rangeHeader != "" {
start, end, valid, err := ParseRangeHeader(rangeHeader, int64(len(blobData)))
if err != nil {
s.setErrorResponse(w, http.StatusRequestedRangeNotSatisfiable, err.Error())
return
}
if valid {
WriteRangeResponse(w, blobData, start, end, int64(len(blobData)))
return
}
}
// Send full blob
w.WriteHeader(http.StatusOK)
_, _ = w.Write(blobData)
}
// handleHeadBlob handles HEAD /<sha256> requests (BUD-01)
func (s *Server) handleHeadBlob(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
// Extract SHA256 and extension
sha256Hex, ext, err := ExtractSHA256FromPath(path)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, err.Error())
return
}
// Convert hex to bytes
sha256Hash, err := hex.Dec(sha256Hex)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
return
}
// Check if blob exists
exists, err := s.storage.HasBlob(sha256Hash)
if err != nil {
log.E.F("error checking blob existence: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
if !exists {
s.setErrorResponse(w, http.StatusNotFound, "blob not found")
return
}
// Get blob metadata
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
if err != nil {
log.E.F("error getting blob metadata: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Optional authorization check
if s.requireAuth {
authEv, err := ValidateAuthEventForGet(r, s.baseURL, sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
if authEv == nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
}
// Set headers (same as GET but no body)
mimeType := DetectMimeType(metadata.MimeType, ext)
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Length", strconv.FormatInt(metadata.Size, 10))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusOK)
}
// handleUpload handles PUT /upload requests (BUD-02)
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
// Check ACL
pubkey, _ := GetPubkeyFromRequest(r)
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(pubkey, remoteAddr, "write") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
return
}
// Read request body
body, err := io.ReadAll(io.LimitReader(r.Body, s.maxBlobSize+1))
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "error reading request body")
return
}
if int64(len(body)) > s.maxBlobSize {
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize))
return
}
// Calculate SHA256
sha256Hash := CalculateSHA256(body)
sha256Hex := hex.Enc(sha256Hash)
// Check if blob already exists
exists, err := s.storage.HasBlob(sha256Hash)
if err != nil {
log.E.F("error checking blob existence: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Optional authorization validation
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv != nil {
pubkey = authEv.Pubkey
}
}
if len(pubkey) == 0 {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Detect MIME type
mimeType := DetectMimeType(
r.Header.Get("Content-Type"),
GetFileExtensionFromPath(r.URL.Path),
)
// Check allowed MIME types
if len(s.allowedMimeTypes) > 0 && !s.allowedMimeTypes[mimeType] {
s.setErrorResponse(w, http.StatusUnsupportedMediaType,
fmt.Sprintf("MIME type %s not allowed", mimeType))
return
}
// Save blob if it doesn't exist
if !exists {
if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType); err != nil {
log.E.F("error saving blob: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
return
}
} else {
// Verify ownership
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
if err != nil {
log.E.F("error getting blob metadata: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Allow if same pubkey or if ACL allows
if !utils.FastEqual(metadata.Pubkey, pubkey) && !s.checkACL(pubkey, remoteAddr, "admin") {
s.setErrorResponse(w, http.StatusConflict, "blob already exists")
return
}
}
// Build URL with extension
ext := ""
if mimeExt := GetMimeTypeFromExtension(GetFileExtensionFromPath(r.URL.Path)); mimeExt != "application/octet-stream" {
// Try to infer extension from MIME type
for extName, mime := range map[string]string{
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
} {
if mime == mimeType {
ext = extName
break
}
}
}
blobURL := BuildBlobURL(s.baseURL, sha256Hex, ext)
if !strings.HasSuffix(blobURL, "/") && !strings.HasPrefix(ext, "/") {
blobURL = s.baseURL + "/" + sha256Hex + ext
}
// Create descriptor
descriptor := NewBlobDescriptor(
blobURL,
sha256Hex,
int64(len(body)),
mimeType,
time.Now().Unix(),
)
// Return descriptor
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err = json.NewEncoder(w).Encode(descriptor); err != nil {
log.E.F("error encoding response: %v", err)
}
}
// handleUploadRequirements handles HEAD /upload requests (BUD-06)
func (s *Server) handleUploadRequirements(w http.ResponseWriter, r *http.Request) {
// Get headers
sha256Hex := r.Header.Get("X-SHA-256")
contentLengthStr := r.Header.Get("X-Content-Length")
contentType := r.Header.Get("X-Content-Type")
// Validate SHA256 header
if sha256Hex == "" {
s.setErrorResponse(w, http.StatusBadRequest, "missing X-SHA-256 header")
return
}
if !ValidateSHA256Hex(sha256Hex) {
s.setErrorResponse(w, http.StatusBadRequest, "invalid X-SHA-256 header format")
return
}
// Validate Content-Length header
if contentLengthStr == "" {
s.setErrorResponse(w, http.StatusLengthRequired, "missing X-Content-Length header")
return
}
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid X-Content-Length header")
return
}
if contentLength > s.maxBlobSize {
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("file too large: max %d bytes", s.maxBlobSize))
return
}
// Check MIME type if provided
if contentType != "" && len(s.allowedMimeTypes) > 0 {
if !s.allowedMimeTypes[contentType] {
s.setErrorResponse(w, http.StatusUnsupportedMediaType,
fmt.Sprintf("unsupported file type: %s", contentType))
return
}
}
// Check if blob already exists
sha256Hash, err := hex.Dec(sha256Hex)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
return
}
exists, err := s.storage.HasBlob(sha256Hash)
if err != nil {
log.E.F("error checking blob existence: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
if exists {
// Return 200 OK - blob already exists, upload can proceed
w.WriteHeader(http.StatusOK)
return
}
// Optional authorization check
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv == nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Check ACL
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(authEv.Pubkey, remoteAddr, "write") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
return
}
}
// All checks passed
w.WriteHeader(http.StatusOK)
}
// handleListBlobs handles GET /list/<pubkey> requests (BUD-02)
func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
// Extract pubkey from path: list/<pubkey>
if !strings.HasPrefix(path, "list/") {
s.setErrorResponse(w, http.StatusBadRequest, "invalid path")
return
}
pubkeyHex := strings.TrimPrefix(path, "list/")
if len(pubkeyHex) != 64 {
s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format")
return
}
pubkey, err := hex.Dec(pubkeyHex)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format")
return
}
// Parse query parameters
var since, until int64
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
since, err = strconv.ParseInt(sinceStr, 10, 64)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid since parameter")
return
}
}
if untilStr := r.URL.Query().Get("until"); untilStr != "" {
until, err = strconv.ParseInt(untilStr, 10, 64)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid until parameter")
return
}
}
// Optional authorization check
requestPubkey, _ := GetPubkeyFromRequest(r)
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "list", nil)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv != nil {
requestPubkey = authEv.Pubkey
}
}
// Check if requesting own list or has admin access
if !utils.FastEqual(pubkey, requestPubkey) && !s.checkACL(requestPubkey, s.getRemoteAddr(r), "admin") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
return
}
// List blobs
descriptors, err := s.storage.ListBlobs(pubkey, since, until)
if err != nil {
log.E.F("error listing blobs: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Set URLs for descriptors
for _, desc := range descriptors {
desc.URL = BuildBlobURL(s.baseURL, desc.SHA256, "")
}
// Return JSON array
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err = json.NewEncoder(w).Encode(descriptors); err != nil {
log.E.F("error encoding response: %v", err)
}
}
// handleDeleteBlob handles DELETE /<sha256> requests (BUD-02)
func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
// Extract SHA256
sha256Hex, _, err := ExtractSHA256FromPath(path)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, err.Error())
return
}
sha256Hash, err := hex.Dec(sha256Hex)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
return
}
// Authorization required for delete
authEv, err := ValidateAuthEvent(r, "delete", sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv == nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Check ACL
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(authEv.Pubkey, remoteAddr, "write") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
return
}
// Verify ownership
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusNotFound, "blob not found")
return
}
if !utils.FastEqual(metadata.Pubkey, authEv.Pubkey) && !s.checkACL(authEv.Pubkey, remoteAddr, "admin") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions to delete this blob")
return
}
// Delete blob
if err = s.storage.DeleteBlob(sha256Hash, authEv.Pubkey); err != nil {
log.E.F("error deleting blob: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "error deleting blob")
return
}
w.WriteHeader(http.StatusOK)
}
// handleMirror handles PUT /mirror requests (BUD-04)
func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) {
// Check ACL
pubkey, _ := GetPubkeyFromRequest(r)
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(pubkey, remoteAddr, "write") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
return
}
// Read request body (JSON with URL)
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if req.URL == "" {
s.setErrorResponse(w, http.StatusBadRequest, "missing url field")
return
}
// Parse URL
mirrorURL, err := url.Parse(req.URL)
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid URL")
return
}
// Download blob from remote URL
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(mirrorURL.String())
if err != nil {
s.setErrorResponse(w, http.StatusBadGateway, "failed to fetch blob from remote URL")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.setErrorResponse(w, http.StatusBadGateway,
fmt.Sprintf("remote server returned status %d", resp.StatusCode))
return
}
// Read blob data
body, err := io.ReadAll(io.LimitReader(resp.Body, s.maxBlobSize+1))
if err != nil {
s.setErrorResponse(w, http.StatusBadGateway, "error reading remote blob")
return
}
if int64(len(body)) > s.maxBlobSize {
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize))
return
}
// Calculate SHA256
sha256Hash := CalculateSHA256(body)
sha256Hex := hex.Enc(sha256Hash)
// Optional authorization validation
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv != nil {
pubkey = authEv.Pubkey
}
}
if len(pubkey) == 0 {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Detect MIME type from remote response
mimeType := DetectMimeType(
resp.Header.Get("Content-Type"),
GetFileExtensionFromPath(mirrorURL.Path),
)
// Save blob
if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType); err != nil {
log.E.F("error saving mirrored blob: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
return
}
// Build URL
blobURL := BuildBlobURL(s.baseURL, sha256Hex, "")
// Create descriptor
descriptor := NewBlobDescriptor(
blobURL,
sha256Hex,
int64(len(body)),
mimeType,
time.Now().Unix(),
)
// Return descriptor
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err = json.NewEncoder(w).Encode(descriptor); err != nil {
log.E.F("error encoding response: %v", err)
}
}
// handleMediaUpload handles PUT /media requests (BUD-05)
func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
// Check ACL
pubkey, _ := GetPubkeyFromRequest(r)
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(pubkey, remoteAddr, "write") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
return
}
// Read request body
body, err := io.ReadAll(io.LimitReader(r.Body, s.maxBlobSize+1))
if err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "error reading request body")
return
}
if int64(len(body)) > s.maxBlobSize {
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize))
return
}
// Calculate SHA256 for authorization validation
sha256Hash := CalculateSHA256(body)
// Optional authorization validation
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "media", sha256Hash)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv != nil {
pubkey = authEv.Pubkey
}
}
if len(pubkey) == 0 {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Optimize media (placeholder - actual optimization would be implemented here)
optimizedData, mimeType := OptimizeMedia(body, DetectMimeType(
r.Header.Get("Content-Type"),
GetFileExtensionFromPath(r.URL.Path),
))
// Calculate optimized blob SHA256
optimizedHash := CalculateSHA256(optimizedData)
optimizedHex := hex.Enc(optimizedHash)
// Save optimized blob
if err = s.storage.SaveBlob(optimizedHash, optimizedData, pubkey, mimeType); err != nil {
log.E.F("error saving optimized blob: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
return
}
// Build URL
blobURL := BuildBlobURL(s.baseURL, optimizedHex, "")
// Create descriptor
descriptor := NewBlobDescriptor(
blobURL,
optimizedHex,
int64(len(optimizedData)),
mimeType,
time.Now().Unix(),
)
// Return descriptor
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err = json.NewEncoder(w).Encode(descriptor); err != nil {
log.E.F("error encoding response: %v", err)
}
}
// handleMediaHead handles HEAD /media requests (BUD-05)
func (s *Server) handleMediaHead(w http.ResponseWriter, r *http.Request) {
// Similar to handleUploadRequirements but for media
// Return 200 OK if media optimization is available
w.WriteHeader(http.StatusOK)
}
// handleReport handles PUT /report requests (BUD-09)
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
// Check ACL
pubkey, _ := GetPubkeyFromRequest(r)
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(pubkey, remoteAddr, "read") {
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
return
}
// Read request body (NIP-56 report event)
var reportEv event.E
if err := json.NewDecoder(r.Body).Decode(&reportEv); err != nil {
s.setErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate report event (kind 1984 per NIP-56)
if reportEv.Kind != 1984 {
s.setErrorResponse(w, http.StatusBadRequest, "invalid event kind, expected 1984")
return
}
// Verify signature
valid, err := reportEv.Verify()
if err != nil || !valid {
s.setErrorResponse(w, http.StatusUnauthorized, "invalid event signature")
return
}
// Extract x tags (blob hashes)
xTags := reportEv.Tags.GetAll([]byte("x"))
if len(xTags) == 0 {
s.setErrorResponse(w, http.StatusBadRequest, "report event missing 'x' tags")
return
}
// Serialize report event
reportData := reportEv.Serialize()
// Save report for each blob hash
for _, xTag := range xTags {
sha256Hex := string(xTag.Value())
if !ValidateSHA256Hex(sha256Hex) {
continue
}
sha256Hash, err := hex.Dec(sha256Hex)
if err != nil {
continue
}
if err = s.storage.SaveReport(sha256Hash, reportData); err != nil {
log.E.F("error saving report: %v", err)
}
}
w.WriteHeader(http.StatusOK)
}

19
pkg/blossom/media.go Normal file
View File

@@ -0,0 +1,19 @@
package blossom
// OptimizeMedia optimizes media content (BUD-05)
// This is a placeholder implementation - actual optimization would use
// libraries like image processing, video encoding, etc.
func OptimizeMedia(data []byte, mimeType string) (optimizedData []byte, optimizedMimeType string) {
// For now, just return the original data unchanged
// In a real implementation, this would:
// - Resize images to optimal dimensions
// - Compress images (JPEG quality, PNG optimization)
// - Convert formats if beneficial
// - Optimize video encoding
// - etc.
optimizedData = data
optimizedMimeType = mimeType
return
}

53
pkg/blossom/payment.go Normal file
View File

@@ -0,0 +1,53 @@
package blossom
import (
"net/http"
)
// PaymentChecker handles payment requirements (BUD-07)
type PaymentChecker struct {
// Payment configuration would go here
// For now, this is a placeholder
}
// NewPaymentChecker creates a new payment checker
func NewPaymentChecker() *PaymentChecker {
return &PaymentChecker{}
}
// CheckPaymentRequired checks if payment is required for an endpoint
// Returns payment method headers if payment is required
func (pc *PaymentChecker) CheckPaymentRequired(
endpoint string,
) (required bool, paymentHeaders map[string]string) {
// Placeholder implementation - always returns false
// In a real implementation, this would check:
// - Per-endpoint payment requirements
// - User payment status
// - Blob size/cost thresholds
// etc.
return false, nil
}
// ValidatePayment validates a payment proof
func (pc *PaymentChecker) ValidatePayment(
paymentMethod, proof string,
) (valid bool, err error) {
// Placeholder implementation
// In a real implementation, this would validate:
// - Cashu tokens (NUT-24)
// - Lightning payment preimages (BOLT-11)
// etc.
return true, nil
}
// SetPaymentRequired sets a 402 Payment Required response with payment headers
func SetPaymentRequired(w http.ResponseWriter, paymentHeaders map[string]string) {
for header, value := range paymentHeaders {
w.Header().Set(header, value)
}
w.WriteHeader(http.StatusPaymentRequired)
}

200
pkg/blossom/server.go Normal file
View File

@@ -0,0 +1,200 @@
package blossom
import (
"net/http"
"strings"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
)
// Server provides a Blossom server implementation
type Server struct {
db *database.D
storage *Storage
acl *acl.S
baseURL string
// Configuration
maxBlobSize int64
allowedMimeTypes map[string]bool
requireAuth bool
}
// Config holds configuration for the Blossom server
type Config struct {
BaseURL string
MaxBlobSize int64
AllowedMimeTypes []string
RequireAuth bool
}
// NewServer creates a new Blossom server instance
func NewServer(db *database.D, aclRegistry *acl.S, cfg *Config) *Server {
if cfg == nil {
cfg = &Config{
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
RequireAuth: false,
}
}
storage := NewStorage(db)
// Build allowed MIME types map
allowedMap := make(map[string]bool)
if len(cfg.AllowedMimeTypes) > 0 {
for _, mime := range cfg.AllowedMimeTypes {
allowedMap[mime] = true
}
}
return &Server{
db: db,
storage: storage,
acl: aclRegistry,
baseURL: cfg.BaseURL,
maxBlobSize: cfg.MaxBlobSize,
allowedMimeTypes: allowedMap,
requireAuth: cfg.RequireAuth,
}
}
// Handler returns an http.Handler that can be attached to a router
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers (BUD-01 requirement)
s.setCORSHeaders(w, r)
// Handle preflight OPTIONS requests
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Route based on path and method
path := r.URL.Path
// Remove leading slash
path = strings.TrimPrefix(path, "/")
// Handle specific endpoints
switch {
case r.Method == http.MethodGet && path == "upload":
// This shouldn't happen, but handle gracefully
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
case r.Method == http.MethodHead && path == "upload":
s.handleUploadRequirements(w, r)
return
case r.Method == http.MethodPut && path == "upload":
s.handleUpload(w, r)
return
case r.Method == http.MethodHead && path == "media":
s.handleMediaHead(w, r)
return
case r.Method == http.MethodPut && path == "media":
s.handleMediaUpload(w, r)
return
case r.Method == http.MethodPut && path == "mirror":
s.handleMirror(w, r)
return
case r.Method == http.MethodPut && path == "report":
s.handleReport(w, r)
return
case strings.HasPrefix(path, "list/"):
if r.Method == http.MethodGet {
s.handleListBlobs(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
case r.Method == http.MethodGet:
// Handle GET /<sha256>
s.handleGetBlob(w, r)
return
case r.Method == http.MethodHead:
// Handle HEAD /<sha256>
s.handleHeadBlob(w, r)
return
case r.Method == http.MethodDelete:
// Handle DELETE /<sha256>
s.handleDeleteBlob(w, r)
return
default:
http.Error(w, "Not found", http.StatusNotFound)
return
}
})
}
// setCORSHeaders sets CORS headers as required by BUD-01
func (s *Server) setCORSHeaders(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, *")
w.Header().Set("Access-Control-Max-Age", "86400")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
}
// setErrorResponse sets an error response with X-Reason header (BUD-01)
func (s *Server) setErrorResponse(w http.ResponseWriter, status int, reason string) {
w.Header().Set("X-Reason", reason)
http.Error(w, reason, status)
}
// getRemoteAddr extracts the remote address from the request
func (s *Server) getRemoteAddr(r *http.Request) string {
// Check X-Forwarded-For header
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {
return strings.TrimSpace(parts[0])
}
}
// Check X-Real-IP header
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}
// Fall back to RemoteAddr
return r.RemoteAddr
}
// checkACL checks if the user has the required access level
func (s *Server) checkACL(
pubkey []byte, remoteAddr string, requiredLevel string,
) bool {
if s.acl == nil {
return true // No ACL configured, allow all
}
level := s.acl.GetAccessLevel(pubkey, remoteAddr)
// Map ACL levels to permissions
levelMap := map[string]int{
"none": 0,
"read": 1,
"write": 2,
"admin": 3,
"owner": 4,
}
required := levelMap[requiredLevel]
actual := levelMap[level]
return actual >= required
}

334
pkg/blossom/storage.go Normal file
View File

@@ -0,0 +1,334 @@
package blossom
import (
"encoding/json"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"next.orly.dev/pkg/crypto/sha256"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/utils"
)
const (
// Database key prefixes
prefixBlobData = "blob:data:"
prefixBlobMeta = "blob:meta:"
prefixBlobIndex = "blob:index:"
prefixBlobReport = "blob:report:"
)
// Storage provides blob storage operations
type Storage struct {
db *database.D
}
// NewStorage creates a new storage instance
func NewStorage(db *database.D) *Storage {
return &Storage{db: db}
}
// SaveBlob stores a blob with its metadata
func (s *Storage) SaveBlob(
sha256Hash []byte, data []byte, pubkey []byte, mimeType string,
) (err error) {
sha256Hex := hex.Enc(sha256Hash)
// Verify SHA256 matches
calculatedHash := sha256.Sum256(data)
if !utils.FastEqual(calculatedHash[:], sha256Hash) {
err = errorf.E(
"SHA256 mismatch: calculated %x, provided %x",
calculatedHash[:], sha256Hash,
)
return
}
// Create metadata
metadata := NewBlobMetadata(pubkey, mimeType, int64(len(data)))
var metaData []byte
if metaData, err = metadata.Serialize(); chk.E(err) {
return
}
// Store blob data
dataKey := prefixBlobData + sha256Hex
if err = s.db.Update(func(txn *badger.Txn) error {
if err := txn.Set([]byte(dataKey), data); err != nil {
return err
}
// Store metadata
metaKey := prefixBlobMeta + sha256Hex
if err := txn.Set([]byte(metaKey), metaData); err != nil {
return err
}
// Index by pubkey
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
if err := txn.Set([]byte(indexKey), []byte{1}); err != nil {
return err
}
return nil
}); chk.E(err) {
return
}
log.D.F("saved blob %s (%d bytes) for pubkey %s", sha256Hex, len(data), hex.Enc(pubkey))
return
}
// GetBlob retrieves blob data by SHA256 hash
func (s *Storage) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) {
sha256Hex := hex.Enc(sha256Hash)
dataKey := prefixBlobData + sha256Hex
var blobData []byte
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(dataKey))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
blobData = make([]byte, len(val))
copy(blobData, val)
return nil
})
}); chk.E(err) {
return
}
// Get metadata
metaKey := prefixBlobMeta + sha256Hex
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
})
}); chk.E(err) {
return
}
data = blobData
return
}
// HasBlob checks if a blob exists
func (s *Storage) HasBlob(sha256Hash []byte) (exists bool, err error) {
sha256Hex := hex.Enc(sha256Hash)
dataKey := prefixBlobData + sha256Hex
if err = s.db.View(func(txn *badger.Txn) error {
_, err := txn.Get([]byte(dataKey))
if err == badger.ErrKeyNotFound {
exists = false
return nil
}
if err != nil {
return err
}
exists = true
return nil
}); chk.E(err) {
return
}
return
}
// DeleteBlob deletes a blob and its metadata
func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) {
sha256Hex := hex.Enc(sha256Hash)
dataKey := prefixBlobData + sha256Hex
metaKey := prefixBlobMeta + sha256Hex
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
if err = s.db.Update(func(txn *badger.Txn) error {
// Verify blob exists
_, err := txn.Get([]byte(dataKey))
if err == badger.ErrKeyNotFound {
return errorf.E("blob %s not found", sha256Hex)
}
if err != nil {
return err
}
// Delete blob data
if err := txn.Delete([]byte(dataKey)); err != nil {
return err
}
// Delete metadata
if err := txn.Delete([]byte(metaKey)); err != nil {
return err
}
// Delete index entry
if err := txn.Delete([]byte(indexKey)); err != nil {
return err
}
return nil
}); chk.E(err) {
return
}
log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey))
return
}
// ListBlobs lists all blobs for a given pubkey
func (s *Storage) ListBlobs(
pubkey []byte, since, until int64,
) (descriptors []*BlobDescriptor, err error) {
pubkeyHex := hex.Enc(pubkey)
prefix := prefixBlobIndex + pubkeyHex + ":"
descriptors = make([]*BlobDescriptor, 0)
if err = s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefix)
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
key := item.Key()
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
sha256Hex := string(key[len(prefix):])
// Get blob metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, err := txn.Get([]byte(metaKey))
if err != nil {
continue
}
var metadata *BlobMetadata
if err = metaItem.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
}); err != nil {
continue
}
// Filter by time range
if since > 0 && metadata.Uploaded < since {
continue
}
if until > 0 && metadata.Uploaded > until {
continue
}
// Verify blob exists
dataKey := prefixBlobData + sha256Hex
_, errGet := txn.Get([]byte(dataKey))
if errGet != nil {
continue
}
// Create descriptor (URL will be set by handler)
descriptor := NewBlobDescriptor(
"", // URL will be set by handler
sha256Hex,
metadata.Size,
metadata.MimeType,
metadata.Uploaded,
)
descriptors = append(descriptors, descriptor)
}
return nil
}); chk.E(err) {
return
}
return
}
// SaveReport stores a report for a blob (BUD-09)
func (s *Storage) SaveReport(sha256Hash []byte, reportData []byte) (err error) {
sha256Hex := hex.Enc(sha256Hash)
reportKey := prefixBlobReport + sha256Hex
// Get existing reports
var existingReports [][]byte
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(reportKey))
if err == badger.ErrKeyNotFound {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if err = json.Unmarshal(val, &existingReports); err != nil {
return err
}
return nil
})
}); chk.E(err) {
return
}
// Append new report
existingReports = append(existingReports, reportData)
// Store updated reports
var reportsData []byte
if reportsData, err = json.Marshal(existingReports); chk.E(err) {
return
}
if err = s.db.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(reportKey), reportsData)
}); chk.E(err) {
return
}
log.D.F("saved report for blob %s", sha256Hex)
return
}
// GetBlobMetadata retrieves only metadata for a blob
func (s *Storage) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, err error) {
sha256Hex := hex.Enc(sha256Hash)
metaKey := prefixBlobMeta + sha256Hex
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
})
}); chk.E(err) {
return
}
return
}

250
pkg/blossom/utils.go Normal file
View 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
}