blossom.go raw

   1  package main
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"crypto/sha256"
   7  	"encoding/base64"
   8  	"encoding/hex"
   9  	"encoding/json"
  10  	"fmt"
  11  	"io"
  12  	"log"
  13  	"net/http"
  14  	"os"
  15  	"sync"
  16  	"time"
  17  
  18  	"git.mleku.dev/mleku/dendrite/pkg/nostr"
  19  )
  20  
  21  // BlobInfo holds the hash and URLs of a successfully uploaded binary blob.
  22  type BlobInfo struct {
  23  	SHA256 string   // hex-encoded sha256 of the binary
  24  	URLs   []string // blossom URLs where the blob was accepted
  25  }
  26  
  27  // Well-known public blossom servers that accept uploads from any npub.
  28  var blossomServers = []string{
  29  	"https://blossom.primal.net",
  30  	"https://blossom.nostr.build",
  31  	"https://cdn.satellite.earth",
  32  	"https://cdn.nostrcheck.me",
  33  	"https://blosstr.com",
  34  	"https://files.v0l.io",
  35  	"https://nostrmedia.com",
  36  }
  37  
  38  // UploadSelf reads the running binary from /proc/self/exe (Linux),
  39  // computes its sha256, and uploads it to all known blossom servers.
  40  // Returns blob info with the hash and successful upload URLs.
  41  func UploadSelf(ctx context.Context) (*BlobInfo, error) {
  42  	// Read our own binary.
  43  	binary, err := os.ReadFile("/proc/self/exe")
  44  	if err != nil {
  45  		return nil, fmt.Errorf("read self: %w", err)
  46  	}
  47  
  48  	h := sha256.Sum256(binary)
  49  	hexHash := hex.EncodeToString(h[:])
  50  	log.Printf("sentry binary: %d bytes, sha256 %s", len(binary), hexHash)
  51  
  52  	// Generate a throwaway identity for the upload auth.
  53  	id, err := nostr.NewIdentity()
  54  	if err != nil {
  55  		return nil, fmt.Errorf("keygen for blossom: %w", err)
  56  	}
  57  
  58  	info := &BlobInfo{SHA256: hexHash}
  59  	var mu sync.Mutex
  60  	var wg sync.WaitGroup
  61  
  62  	for _, server := range blossomServers {
  63  		wg.Add(1)
  64  		go func(server string) {
  65  			defer wg.Done()
  66  			url, err := uploadBlob(ctx, server, binary, hexHash, id)
  67  			if err != nil {
  68  				log.Printf("blossom %s: %v", server, err)
  69  				return
  70  			}
  71  			mu.Lock()
  72  			info.URLs = append(info.URLs, url)
  73  			mu.Unlock()
  74  			log.Printf("blossom %s: uploaded → %s", server, url)
  75  		}(server)
  76  	}
  77  
  78  	wg.Wait()
  79  	log.Printf("blossom upload: %d/%d servers accepted", len(info.URLs), len(blossomServers))
  80  	return info, nil
  81  }
  82  
  83  // uploadBlob uploads binary data to a single blossom server using BUD-02.
  84  func uploadBlob(ctx context.Context, server string, data []byte, hexHash string, id *nostr.Identity) (string, error) {
  85  	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
  86  	defer cancel()
  87  
  88  	// Compose kind-24242 authorization event (BUD-02).
  89  	authEvent := &nostr.Event{
  90  		CreatedAt: time.Now().Unix(),
  91  		Kind:      24242,
  92  		Tags: [][]string{
  93  			{"t", "upload"},
  94  			{"x", hexHash},
  95  			{"expiration", fmt.Sprintf("%d", time.Now().Add(5*time.Minute).Unix())},
  96  		},
  97  		Content: "sentry binary upload",
  98  	}
  99  	if err := authEvent.Sign(id.PrivKeyHex()); err != nil {
 100  		return "", fmt.Errorf("sign auth: %w", err)
 101  	}
 102  
 103  	// Base64-encode the auth event for the Authorization header.
 104  	authJSON, _ := json.Marshal(authEvent)
 105  	authB64 := base64.StdEncoding.EncodeToString(authJSON)
 106  
 107  	req, err := http.NewRequestWithContext(ctx, "PUT", server+"/upload", bytes.NewReader(data))
 108  	if err != nil {
 109  		return "", err
 110  	}
 111  	req.Header.Set("Authorization", "Nostr "+authB64)
 112  	req.Header.Set("Content-Type", "application/octet-stream")
 113  
 114  	resp, err := http.DefaultClient.Do(req)
 115  	if err != nil {
 116  		return "", err
 117  	}
 118  	defer resp.Body.Close()
 119  
 120  	body, _ := io.ReadAll(resp.Body)
 121  
 122  	if resp.StatusCode != 200 {
 123  		// Truncate error body for logging.
 124  		msg := string(body)
 125  		if len(msg) > 200 {
 126  			msg = msg[:200]
 127  		}
 128  		return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
 129  	}
 130  
 131  	// Parse blob descriptor response.
 132  	var desc struct {
 133  		URL    string `json:"url"`
 134  		SHA256 string `json:"sha256"`
 135  	}
 136  	if err := json.Unmarshal(body, &desc); err != nil {
 137  		return "", fmt.Errorf("parse response: %w", err)
 138  	}
 139  
 140  	if desc.URL != "" {
 141  		return desc.URL, nil
 142  	}
 143  	// Fallback: construct URL from server + hash.
 144  	return fmt.Sprintf("%s/%s", server, hexHash), nil
 145  }
 146