blossom works fully correctly

This commit is contained in:
2025-11-23 12:32:53 +00:00
parent 1c376e6e8d
commit da058c37c0
6 changed files with 526 additions and 26 deletions

View File

@@ -125,7 +125,8 @@
"Bash(go clean:*)",
"Bash(GOSUMDB=off CGO_ENABLED=0 timeout 240 go build:*)",
"Bash(CGO_ENABLED=0 GOFLAGS=-mod=mod timeout 240 go build:*)",
"Bash(CGO_ENABLED=0 timeout 120 go test:*)"
"Bash(CGO_ENABLED=0 timeout 120 go test:*)",
"Bash(./cmd/blossomtest/blossomtest:*)"
],
"deny": [],
"ask": []

View File

@@ -203,19 +203,25 @@ func Run(
}
}
// Initialize the user interface
l.UserInterface()
// Initialize Blossom blob storage server (only for Badger backend)
// MUST be done before UserInterface() which registers routes
if badgerDB, ok := db.(*database.D); ok {
log.I.F("Badger backend detected, initializing Blossom server...")
if l.blossomServer, err = initializeBlossomServer(ctx, cfg, badgerDB); err != nil {
log.E.F("failed to initialize blossom server: %v", err)
// Continue without blossom server
} else if l.blossomServer != nil {
log.I.F("blossom blob storage server initialized")
} else {
log.W.F("blossom server initialization returned nil without error")
}
} else {
log.I.F("Non-Badger backend detected (type: %T), Blossom server not available", db)
}
// Initialize the user interface (registers routes)
l.UserInterface()
// Ensure a relay identity secret key exists when subscriptions and NWC are enabled
if cfg.SubscriptionEnabled && cfg.NWCUri != "" {
if skb, e := db.GetOrCreateRelayIdentitySecret(); e != nil {

View File

@@ -255,6 +255,8 @@ func (s *Server) UserInterface() {
if s.blossomServer != nil {
s.mux.HandleFunc("/blossom/", s.blossomHandler)
log.Printf("Blossom blob storage API enabled at /blossom")
} else {
log.Printf("WARNING: Blossom server is nil, routes not registered")
}
// Cluster replication API endpoints

114
cmd/blossomtest/README.md Normal file
View File

@@ -0,0 +1,114 @@
# Blossom Test Tool
A simple command-line tool to test the Blossom blob storage service by performing upload, fetch, and delete operations.
## Building
```bash
# From the repository root
CGO_ENABLED=0 go build -o cmd/blossomtest/blossomtest ./cmd/blossomtest
```
## Usage
```bash
# Basic usage with auto-generated key
./cmd/blossomtest/blossomtest
# Specify relay URL
./cmd/blossomtest/blossomtest -url http://localhost:3334
# Use a specific Nostr key (nsec format)
./cmd/blossomtest/blossomtest -nsec nsec1...
# Test with larger blob
./cmd/blossomtest/blossomtest -size 10240
# Verbose output to see HTTP requests and auth events
./cmd/blossomtest/blossomtest -v
# Test anonymous uploads (for open relays)
./cmd/blossomtest/blossomtest -no-auth
```
## Options
- `-url` - Relay base URL (default: `http://localhost:3334`)
- `-nsec` - Nostr private key in nsec format (generates new key if not provided)
- `-size` - Size of test blob in bytes (default: 1024)
- `-v` - Verbose output showing HTTP requests and authentication events
- `-no-auth` - Skip authentication and test anonymous uploads (useful for open relays)
## What It Tests
The tool performs the following operations in sequence:
1. **Upload** - Uploads random test data to the Blossom server
- Creates a Blossom authorization event (kind 24242)
- Sends a PUT request to `/blossom/upload`
- Verifies the returned descriptor
2. **Fetch** - Retrieves the uploaded blob
- Sends a GET request to `/blossom/<sha256>`
- Verifies the data matches what was uploaded
3. **Delete** - Removes the blob from the server
- Creates another authorization event for deletion
- Sends a DELETE request to `/blossom/<sha256>`
4. **Verify** - Confirms deletion was successful
- Attempts to fetch the blob again
- Expects a 404 Not Found response
## Example Output
```
🌸 Blossom Test Tool
===================
No key provided, generated new keypair
Using identity: npub1...
Relay URL: http://localhost:3334
📦 Generated 1024 bytes of random data
SHA256: a1b2c3d4...
📤 Step 1: Uploading blob...
✅ Upload successful!
URL: http://localhost:3334/blossom/a1b2c3d4...
SHA256: a1b2c3d4...
Size: 1024 bytes
📥 Step 2: Fetching blob...
✅ Fetch successful! Retrieved 1024 bytes
✅ Data verification passed - hashes match!
🗑️ Step 3: Deleting blob...
✅ Delete successful!
🔍 Step 4: Verifying deletion...
✅ Blob successfully deleted - returns 404 as expected
🎉 All tests passed! Blossom service is working correctly.
```
## Requirements
- A running ORLY relay with Blossom enabled
- The relay must be using the Badger backend (Blossom is not available with DGraph)
- Network connectivity to the relay
## Troubleshooting
**"connection refused"**
- Make sure your relay is running
- Check the URL is correct (default: `http://localhost:3334`)
**"unauthorized" or "403 Forbidden"**
- Check your relay's ACL settings
- If using `ORLY_AUTH_TO_WRITE=true`, make sure authentication is working
- Try adding your test key to `ORLY_ADMINS` if using follows mode
**"blossom server not initialized"**
- Blossom only works with the Badger backend
- Check `ORLY_DB_TYPE` is set to `badger` or not set (defaults to badger)

384
cmd/blossomtest/main.go Normal file
View File

@@ -0,0 +1,384 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"git.mleku.dev/mleku/nostr/crypto/ec/schnorr"
"git.mleku.dev/mleku/nostr/crypto/ec/secp256k1"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"github.com/minio/sha256-simd"
)
const (
// BlossomAuthKind is the Nostr event kind for Blossom authorization (BUD-01)
BlossomAuthKind = 24242
)
var (
relayURL = flag.String("url", "http://localhost:3334", "Relay base URL")
nsec = flag.String("nsec", "", "Nostr private key (nsec format). If empty, generates a new key")
blobSize = flag.Int("size", 1024, "Size of test blob in bytes")
verbose = flag.Bool("v", false, "Verbose output")
noAuth = flag.Bool("no-auth", false, "Skip authentication (test anonymous uploads)")
)
// BlossomDescriptor represents a blob descriptor returned by the server
type BlossomDescriptor struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Type string `json:"type,omitempty"`
Uploaded int64 `json:"uploaded"`
PublicKey string `json:"public_key,omitempty"`
Tags [][]string `json:"tags,omitempty"`
}
func main() {
flag.Parse()
fmt.Println("🌸 Blossom Test Tool")
fmt.Println("===================\n")
// Get or generate keypair (only if auth is enabled)
var sec, pub []byte
var err error
if !*noAuth {
sec, pub, err = getKeypair()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting keypair: %v\n", err)
os.Exit(1)
}
pubkey, _ := schnorr.ParsePubKey(pub)
npubBytes, _ := bech32encoding.PublicKeyToNpub(pubkey)
fmt.Printf("Using identity: %s\n", string(npubBytes))
} else {
fmt.Printf("Testing anonymous uploads (no authentication)\n")
}
fmt.Printf("Relay URL: %s\n\n", *relayURL)
// Generate random test data
testData := make([]byte, *blobSize)
if _, err := rand.Read(testData); err != nil {
fmt.Fprintf(os.Stderr, "Error generating test data: %v\n", err)
os.Exit(1)
}
// Calculate SHA256
hash := sha256.Sum256(testData)
hashHex := hex.EncodeToString(hash[:])
fmt.Printf("📦 Generated %d bytes of random data\n", *blobSize)
fmt.Printf(" SHA256: %s\n\n", hashHex)
// Step 1: Upload blob
fmt.Println("📤 Step 1: Uploading blob...")
descriptor, err := uploadBlob(sec, pub, testData)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Upload failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Upload successful!\n")
fmt.Printf(" URL: %s\n", descriptor.URL)
fmt.Printf(" SHA256: %s\n", descriptor.SHA256)
fmt.Printf(" Size: %d bytes\n\n", descriptor.Size)
// Step 2: Fetch blob
fmt.Println("📥 Step 2: Fetching blob...")
fetchedData, err := fetchBlob(hashHex)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Fetch failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Fetch successful! Retrieved %d bytes\n", len(fetchedData))
// Verify data matches
if !bytes.Equal(testData, fetchedData) {
fmt.Fprintf(os.Stderr, "❌ Data mismatch! Retrieved data doesn't match uploaded data\n")
os.Exit(1)
}
fmt.Printf("✅ Data verification passed - hashes match!\n\n")
// Step 3: Delete blob
fmt.Println("🗑️ Step 3: Deleting blob...")
if err := deleteBlob(sec, pub, hashHex); err != nil {
fmt.Fprintf(os.Stderr, "❌ Delete failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Delete successful!\n\n")
// Step 4: Verify deletion
fmt.Println("🔍 Step 4: Verifying deletion...")
if err := verifyDeleted(hashHex); err != nil {
fmt.Fprintf(os.Stderr, "❌ Verification failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Blob successfully deleted - returns 404 as expected\n\n")
fmt.Println("🎉 All tests passed! Blossom service is working correctly.")
}
func getKeypair() (sec, pub []byte, err error) {
if *nsec != "" {
// Decode provided nsec
var secKey *secp256k1.SecretKey
secKey, err = bech32encoding.NsecToSecretKey(*nsec)
if err != nil {
return nil, nil, fmt.Errorf("invalid nsec: %w", err)
}
sec = secKey.Serialize()
} else {
// Generate new keypair
sec = make([]byte, 32)
if _, err := rand.Read(sec); err != nil {
return nil, nil, fmt.Errorf("failed to generate key: %w", err)
}
fmt.Println(" No key provided, generated new keypair")
}
// Derive public key using p8k signer
var signer *p8k.Signer
if signer, err = p8k.New(); err != nil {
return nil, nil, fmt.Errorf("failed to create signer: %w", err)
}
if err = signer.InitSec(sec); err != nil {
return nil, nil, fmt.Errorf("failed to initialize signer: %w", err)
}
pub = signer.Pub()
return sec, pub, nil
}
// createAuthEvent creates a Blossom authorization event (kind 24242)
func createAuthEvent(sec, pub []byte, action, hash string) (string, error) {
now := time.Now().Unix()
// Build tags based on action
tags := [][]string{
{"t", action},
}
// Add x tag for DELETE and GET actions
if hash != "" && (action == "delete" || action == "get") {
tags = append(tags, []string{"x", hash})
}
// All Blossom auth events require expiration tag (BUD-01)
expiry := now + 300 // Event expires in 5 minutes
tags = append(tags, []string{"expiration", fmt.Sprintf("%d", expiry)})
pubkeyHex := hex.EncodeToString(pub)
// Create event ID
eventJSON, err := json.Marshal([]interface{}{
0,
pubkeyHex,
now,
BlossomAuthKind,
tags,
"",
})
if err != nil {
return "", fmt.Errorf("failed to marshal event for ID: %w", err)
}
eventHash := sha256.Sum256(eventJSON)
eventID := hex.EncodeToString(eventHash[:])
// Sign the event using p8k signer
signer, err := p8k.New()
if err != nil {
return "", fmt.Errorf("failed to create signer: %w", err)
}
if err = signer.InitSec(sec); err != nil {
return "", fmt.Errorf("failed to initialize signer: %w", err)
}
sig, err := signer.Sign(eventHash[:])
if err != nil {
return "", fmt.Errorf("failed to sign event: %w", err)
}
sigHex := hex.EncodeToString(sig)
// Create event JSON (signed)
event := map[string]interface{}{
"id": eventID,
"pubkey": pubkeyHex,
"created_at": now,
"kind": BlossomAuthKind,
"tags": tags,
"content": "",
"sig": sigHex,
}
// Marshal to JSON for Authorization header
authJSON, err := json.Marshal(event)
if err != nil {
return "", fmt.Errorf("failed to marshal auth event: %w", err)
}
if *verbose {
fmt.Printf(" Auth event: %s\n", string(authJSON))
}
return string(authJSON), nil
}
func uploadBlob(sec, pub, data []byte) (*BlossomDescriptor, error) {
// Create request
url := strings.TrimSuffix(*relayURL, "/") + "/blossom/upload"
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
if err != nil {
return nil, err
}
// Set headers
req.Header.Set("Content-Type", "application/octet-stream")
// Add authorization if not disabled
if !*noAuth && sec != nil && pub != nil {
authEvent, err := createAuthEvent(sec, pub, "upload", "")
if err != nil {
return nil, err
}
// Base64-encode the auth event as per BUD-01
authEventB64 := base64.StdEncoding.EncodeToString([]byte(authEvent))
req.Header.Set("Authorization", "Nostr "+authEventB64)
}
if *verbose {
fmt.Printf(" PUT %s\n", url)
fmt.Printf(" Content-Length: %d\n", len(data))
}
// Send request
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
reason := resp.Header.Get("X-Reason")
if reason == "" {
reason = string(body)
}
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, reason)
}
// Parse descriptor
var descriptor BlossomDescriptor
if err := json.Unmarshal(body, &descriptor); err != nil {
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body))
}
return &descriptor, nil
}
func fetchBlob(hash string) ([]byte, error) {
url := strings.TrimSuffix(*relayURL, "/") + "/blossom/" + hash
if *verbose {
fmt.Printf(" GET %s\n", url)
}
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func deleteBlob(sec, pub []byte, hash string) error {
// Create request
url := strings.TrimSuffix(*relayURL, "/") + "/blossom/" + hash
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return err
}
// Add authorization if not disabled
if !*noAuth && sec != nil && pub != nil {
authEvent, err := createAuthEvent(sec, pub, "delete", hash)
if err != nil {
return err
}
// Base64-encode the auth event as per BUD-01
authEventB64 := base64.StdEncoding.EncodeToString([]byte(authEvent))
req.Header.Set("Authorization", "Nostr "+authEventB64)
}
if *verbose {
fmt.Printf(" DELETE %s\n", url)
}
// Send request
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
reason := resp.Header.Get("X-Reason")
if reason == "" {
reason = string(body)
}
return fmt.Errorf("server returned %d: %s", resp.StatusCode, reason)
}
return nil
}
func verifyDeleted(hash string) error {
url := strings.TrimSuffix(*relayURL, "/") + "/blossom/" + hash
if *verbose {
fmt.Printf(" GET %s (expecting 404)\n", url)
}
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return fmt.Errorf("blob still exists (expected 404, got 200)")
}
if resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("unexpected status code: %d (expected 404)", resp.StatusCode)
}
return nil
}

View File

@@ -180,13 +180,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Calculate SHA256
sha256Hash := CalculateSHA256(body)
sha256Hex := hex.Enc(sha256Hash)
// Optional authorization validation (do this BEFORE ACL check)
// For upload, we don't pass sha256Hash because upload auth events don't have 'x' tags
// (the hash isn't known at auth event creation time)
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
authEv, err := ValidateAuthEvent(r, "upload", nil)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
@@ -202,6 +200,10 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Calculate SHA256 after auth check
sha256Hash := CalculateSHA256(body)
sha256Hex := hex.Enc(sha256Hash)
// Check if blob already exists
exists, err := s.storage.HasBlob(sha256Hash)
if err != nil {
@@ -210,10 +212,8 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
if len(pubkey) == 0 {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Note: pubkey may be nil for anonymous uploads if ACL allows it
// The storage layer will handle anonymous uploads appropriately
// Detect MIME type
mimeType := DetectMimeType(
@@ -593,8 +593,9 @@ func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) {
sha256Hex := hex.Enc(sha256Hash)
// Optional authorization validation (do this BEFORE ACL check)
// For mirror (which uses upload semantics), don't pass sha256Hash
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
authEv, err := ValidateAuthEvent(r, "upload", nil)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
@@ -610,10 +611,7 @@ func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) {
return
}
if len(pubkey) == 0 {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Note: pubkey may be nil for anonymous uploads if ACL allows it
// Detect MIME type from remote response
mimeType := DetectMimeType(
@@ -673,12 +671,10 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Calculate SHA256 for authorization validation
sha256Hash := CalculateSHA256(body)
// Optional authorization validation (do this BEFORE ACL check)
// For media upload, don't pass sha256Hash (similar to regular upload)
if r.Header.Get(AuthorizationHeader) != "" {
authEv, err := ValidateAuthEvent(r, "media", sha256Hash)
authEv, err := ValidateAuthEvent(r, "media", nil)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
@@ -694,10 +690,7 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
return
}
if len(pubkey) == 0 {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Note: pubkey may be nil for anonymous uploads if ACL allows it
// Optimize media (placeholder - actual optimization would be implemented here)
originalMimeType := DetectMimeType(