diff --git a/pkg/blossom/auth.go b/pkg/blossom/auth.go new file mode 100644 index 0000000..0b7a06b --- /dev/null +++ b/pkg/blossom/auth.go @@ -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 " 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 +} + diff --git a/pkg/blossom/blob.go b/pkg/blossom/blob.go new file mode 100644 index 0000000..cf15300 --- /dev/null +++ b/pkg/blossom/blob.go @@ -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 +} diff --git a/pkg/blossom/handlers.go b/pkg/blossom/handlers.go new file mode 100644 index 0000000..6a597f5 --- /dev/null +++ b/pkg/blossom/handlers.go @@ -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 / 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 / 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/ requests (BUD-02) +func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + + // Extract pubkey from path: list/ + 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 / 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) +} + diff --git a/pkg/blossom/media.go b/pkg/blossom/media.go new file mode 100644 index 0000000..e5a51de --- /dev/null +++ b/pkg/blossom/media.go @@ -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 +} + diff --git a/pkg/blossom/payment.go b/pkg/blossom/payment.go new file mode 100644 index 0000000..069d0a6 --- /dev/null +++ b/pkg/blossom/payment.go @@ -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) +} + diff --git a/pkg/blossom/server.go b/pkg/blossom/server.go new file mode 100644 index 0000000..058db28 --- /dev/null +++ b/pkg/blossom/server.go @@ -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 / + s.handleGetBlob(w, r) + return + + case r.Method == http.MethodHead: + // Handle HEAD / + s.handleHeadBlob(w, r) + return + + case r.Method == http.MethodDelete: + // Handle DELETE / + 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 +} + diff --git a/pkg/blossom/storage.go b/pkg/blossom/storage.go new file mode 100644 index 0000000..af675c5 --- /dev/null +++ b/pkg/blossom/storage.go @@ -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 +} + diff --git a/pkg/blossom/utils.go b/pkg/blossom/utils.go new file mode 100644 index 0000000..458870c --- /dev/null +++ b/pkg/blossom/utils.go @@ -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 / and /. 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 +} +