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