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