main.go raw
1 // Package main is a CLI tool to export all events from an ORLY relay via the
2 // /api/export HTTP endpoint using NIP-98 authentication.
3 package main
4
5 import (
6 "flag"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "os"
12 "strings"
13 "time"
14
15 "next.orly.dev/pkg/lol/chk"
16
17 "next.orly.dev/pkg/nostr/encoders/bech32encoding"
18 "next.orly.dev/pkg/nostr/httpauth"
19 "next.orly.dev/pkg/nostr/interfaces/signer"
20 "next.orly.dev/pkg/nostr/interfaces/signer/p8k"
21
22 "next.orly.dev/pkg/version"
23 )
24
25 var userAgent = fmt.Sprintf("orly-export/%s", strings.TrimSpace(version.V))
26
27 func fail(format string, a ...any) {
28 fmt.Fprintf(os.Stderr, "error: "+format+"\n", a...)
29 os.Exit(1)
30 }
31
32 // progressWriter wraps an io.Writer and tracks bytes written and line count.
33 type progressWriter struct {
34 w io.Writer
35 bytes int64
36 lines int64
37 lastReport time.Time
38 start time.Time
39 }
40
41 func newProgressWriter(w io.Writer) *progressWriter {
42 now := time.Now()
43 return &progressWriter{w: w, start: now, lastReport: now}
44 }
45
46 func (pw *progressWriter) Write(p []byte) (n int, err error) {
47 n, err = pw.w.Write(p)
48 pw.bytes += int64(n)
49 for _, b := range p[:n] {
50 if b == '\n' {
51 pw.lines++
52 }
53 }
54 if time.Since(pw.lastReport) >= 2*time.Second {
55 pw.report()
56 pw.lastReport = time.Now()
57 }
58 return
59 }
60
61 func (pw *progressWriter) report() {
62 elapsed := time.Since(pw.start)
63 mb := float64(pw.bytes) / 1024 / 1024
64 fmt.Fprintf(os.Stderr, "\r %d events, %.2f MB downloaded (%.1fs)",
65 pw.lines, mb, elapsed.Seconds())
66 }
67
68 func (pw *progressWriter) final() {
69 elapsed := time.Since(pw.start)
70 mb := float64(pw.bytes) / 1024 / 1024
71 fmt.Fprintf(os.Stderr, "\r %d events, %.2f MB downloaded in %.1fs\n",
72 pw.lines, mb, elapsed.Seconds())
73 }
74
75 func main() {
76 var (
77 relayURL string
78 nsec string
79 output string
80 )
81
82 flag.StringVar(&relayURL, "relay", "", "relay URL (e.g. https://plebeian.market)")
83 flag.StringVar(&nsec, "nsec", "", "nsec (bech32) for NIP-98 auth (or set NOSTR_SECRET_KEY)")
84 flag.StringVar(&output, "output", "", "output file path (default: auto-generated)")
85 flag.Parse()
86
87 if relayURL == "" {
88 fail("--relay is required (e.g. --relay https://plebeian.market)")
89 }
90
91 // Normalize the relay URL
92 relayURL = strings.TrimRight(relayURL, "/")
93 if !strings.HasPrefix(relayURL, "http") {
94 relayURL = "https://" + relayURL
95 }
96
97 // Get nsec from flag or env
98 if nsec == "" {
99 nsec = os.Getenv("NOSTR_SECRET_KEY")
100 }
101
102 var sign signer.I
103 if nsec != "" {
104 var err error
105 sign, err = makeSigner(nsec)
106 if err != nil {
107 fail("failed to initialize signer: %s", err)
108 }
109 fmt.Fprintf(os.Stderr, "authenticated with NIP-98\n")
110 } else {
111 fmt.Fprintf(os.Stderr, "warning: no nsec provided, attempting unauthenticated export\n")
112 }
113
114 // Build export URL
115 exportURL := relayURL + "/api/export"
116 ur, err := url.Parse(exportURL)
117 if err != nil {
118 fail("invalid URL: %s", err)
119 }
120
121 // Determine output file
122 if output == "" {
123 host := ur.Hostname()
124 host = strings.ReplaceAll(host, ".", "-")
125 output = fmt.Sprintf("export-%s-%s.jsonl",
126 host,
127 time.Now().UTC().Format("20060102-150405Z"))
128 }
129
130 fmt.Fprintf(os.Stderr, "exporting from %s -> %s\n", exportURL, output)
131
132 // Create HTTP request
133 req, err := http.NewRequest("GET", exportURL, nil)
134 if err != nil {
135 fail("failed to create request: %s", err)
136 }
137 req.Header.Set("User-Agent", userAgent)
138
139 if sign != nil {
140 if err = httpauth.AddNIP98Header(req, ur, "GET", "", sign, 0); chk.E(err) {
141 fail("failed to add NIP-98 header: %s", err)
142 }
143 }
144
145 // Execute request with no timeout (export can be large)
146 client := &http.Client{
147 Timeout: 0,
148 }
149 resp, err := client.Do(req)
150 if err != nil {
151 fail("request failed: %s", err)
152 }
153 defer resp.Body.Close()
154
155 if resp.StatusCode != http.StatusOK {
156 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
157 fail("server returned %d: %s", resp.StatusCode, string(body))
158 }
159
160 // Open output file
161 f, err := os.Create(output)
162 if err != nil {
163 fail("failed to create output file: %s", err)
164 }
165 defer f.Close()
166
167 // Stream response to file with progress tracking
168 pw := newProgressWriter(f)
169 if _, err = io.Copy(pw, resp.Body); err != nil {
170 fmt.Fprintln(os.Stderr)
171 fail("download error: %s", err)
172 }
173 pw.final()
174
175 fmt.Fprintf(os.Stderr, "export saved to %s\n", output)
176 }
177
178 func makeSigner(nsec string) (signer.I, error) {
179 sk, err := bech32encoding.NsecToBytes([]byte(nsec))
180 if err != nil {
181 return nil, fmt.Errorf("failed to decode nsec: %w", err)
182 }
183 s, err := p8k.New()
184 if err != nil {
185 return nil, fmt.Errorf("failed to create signer: %w", err)
186 }
187 if err = s.InitSec(sk); err != nil {
188 return nil, fmt.Errorf("failed to init signer: %w", err)
189 }
190 return s, nil
191 }
192