main.go raw
1 package main
2
3 import (
4 "bytes"
5 "encoding/base64"
6 "encoding/hex"
7 "encoding/json"
8 "flag"
9 "fmt"
10 "io"
11 "mime"
12 "net/http"
13 "os"
14 "path/filepath"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/minio/sha256-simd"
20 "git.smesh.lol/orly/pkg/nostr/crypto/ec/secp256k1"
21 "git.smesh.lol/orly/pkg/nostr/encoders/bech32encoding"
22 "git.smesh.lol/orly/pkg/nostr/interfaces/signer/p8k"
23 )
24
25 var (
26 nsec = flag.String("nsec", "", "nostr private key (nsec or hex)")
27 servers = flag.String("servers", "", "comma-separated server URLs (overrides defaults)")
28 verbose = flag.Bool("v", false, "verbose output")
29 )
30
31 var defaultServers = []string{
32 "https://blossom.primal.net",
33 "https://nostr.download",
34 "https://blossom.oxtr.dev",
35 "https://blossom.nostr.build",
36 "https://blossom.band",
37 "https://blossom.nogood.studio",
38 "https://blossom.hzrd149.com",
39 "https://cdn.hzrd149.com",
40 "https://blossom.f7z.io",
41 "https://blossom.azzamo.net",
42 "https://files.sovbit.host",
43 "https://nosto.re",
44 "https://nostrmedia.com",
45 "https://cdn.nostrcheck.me",
46 "https://media-server.slidestr.net",
47 "https://cdn.satellite.earth",
48 "https://files.v0l.io",
49 }
50
51 func main() {
52 flag.Parse()
53 if *nsec == "" {
54 fmt.Fprintln(os.Stderr, "usage: blossom-upload -nsec <key> file1 [file2 ...]")
55 os.Exit(1)
56 }
57 files := flag.Args()
58 if len(files) == 0 {
59 fmt.Fprintln(os.Stderr, "no files specified")
60 os.Exit(1)
61 }
62
63 sec, pub, err := parseKey(*nsec)
64 if err != nil {
65 fmt.Fprintf(os.Stderr, "key error: %v\n", err)
66 os.Exit(1)
67 }
68
69 srvs := defaultServers
70 if *servers != "" {
71 srvs = strings.Split(*servers, ",")
72 }
73
74 for _, path := range files {
75 data, err := os.ReadFile(path)
76 if err != nil {
77 fmt.Fprintf(os.Stderr, "read %s: %v\n", path, err)
78 continue
79 }
80 hash := sha256.Sum256(data)
81 hashHex := hex.EncodeToString(hash[:])
82 ct := mimeType(path)
83 fmt.Printf("%s (%d bytes, %s, sha256:%s)\n", filepath.Base(path), len(data), ct, hashHex[:16]+"...")
84
85 var wg sync.WaitGroup
86 for _, srv := range srvs {
87 wg.Add(1)
88 go func(srv string) {
89 defer wg.Done()
90 url, err := upload(sec, pub, srv, data, ct)
91 if err != nil {
92 fmt.Printf(" %s: FAIL %v\n", srv, err)
93 } else {
94 fmt.Printf(" %s: OK %s\n", srv, url)
95 }
96 }(strings.TrimSpace(srv))
97 }
98 wg.Wait()
99 fmt.Println()
100 }
101 }
102
103 func parseKey(key string) (sec, pub []byte, err error) {
104 if strings.HasPrefix(key, "nsec1") {
105 var sk *secp256k1.SecretKey
106 sk, err = bech32encoding.NsecToSecretKey(key)
107 if err != nil {
108 return
109 }
110 sec = sk.Serialize()
111 } else {
112 sec, err = hex.DecodeString(key)
113 if err != nil {
114 return
115 }
116 }
117 var s *p8k.Signer
118 s, err = p8k.New()
119 if err != nil {
120 return
121 }
122 if err = s.InitSec(sec); err != nil {
123 return
124 }
125 pub = s.Pub()
126 return
127 }
128
129 func authHeader(sec, pub []byte, action, hashHex string) (string, error) {
130 now := time.Now().Unix()
131 tags := [][]string{
132 {"t", action},
133 {"expiration", fmt.Sprintf("%d", now+300)},
134 }
135 if hashHex != "" {
136 tags = append(tags, []string{"x", hashHex})
137 }
138 pubHex := hex.EncodeToString(pub)
139
140 // event ID = sha256 of serialized [0, pubkey, created_at, kind, tags, content]
141 ser, err := json.Marshal([]interface{}{0, pubHex, now, 24242, tags, ""})
142 if err != nil {
143 return "", err
144 }
145 idHash := sha256.Sum256(ser)
146
147 signer, err := p8k.New()
148 if err != nil {
149 return "", err
150 }
151 if err = signer.InitSec(sec); err != nil {
152 return "", err
153 }
154 sig, err := signer.Sign(idHash[:])
155 if err != nil {
156 return "", err
157 }
158
159 ev := map[string]interface{}{
160 "id": hex.EncodeToString(idHash[:]),
161 "pubkey": pubHex,
162 "created_at": now,
163 "kind": 24242,
164 "tags": tags,
165 "content": "",
166 "sig": hex.EncodeToString(sig),
167 }
168 j, err := json.Marshal(ev)
169 if err != nil {
170 return "", err
171 }
172 if *verbose {
173 fmt.Printf(" auth: %s\n", j)
174 }
175 return "Nostr " + base64.StdEncoding.EncodeToString(j), nil
176 }
177
178 func upload(sec, pub []byte, server string, data []byte, contentType string) (string, error) {
179 hash := sha256.Sum256(data)
180 hashHex := hex.EncodeToString(hash[:])
181
182 auth, err := authHeader(sec, pub, "upload", hashHex)
183 if err != nil {
184 return "", err
185 }
186
187 url := strings.TrimSuffix(server, "/") + "/upload"
188 req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
189 if err != nil {
190 return "", err
191 }
192 req.Header.Set("Content-Type", contentType)
193 req.Header.Set("Authorization", auth)
194
195 client := &http.Client{Timeout: 60 * time.Second}
196 resp, err := client.Do(req)
197 if err != nil {
198 return "", err
199 }
200 defer resp.Body.Close()
201
202 body, _ := io.ReadAll(resp.Body)
203 if resp.StatusCode != 200 && resp.StatusCode != 201 {
204 reason := resp.Header.Get("X-Reason")
205 if reason == "" {
206 reason = string(body)
207 if len(reason) > 200 {
208 reason = reason[:200]
209 }
210 }
211 return "", fmt.Errorf("%d: %s", resp.StatusCode, reason)
212 }
213
214 var desc struct {
215 URL string `json:"url"`
216 }
217 if err := json.Unmarshal(body, &desc); err != nil {
218 return string(body), nil
219 }
220 return desc.URL, nil
221 }
222
223 func mimeType(path string) string {
224 ext := filepath.Ext(path)
225 ct := mime.TypeByExtension(ext)
226 if ct == "" {
227 ct = "application/octet-stream"
228 }
229 return ct
230 }
231