Files
next.orly.dev/cmd/blossomtest/main.go
2025-11-23 12:32:53 +00:00

385 lines
9.9 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}