You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

191 lines
4.6 KiB

// Package main is a CLI tool to export all events from an ORLY relay via the
// /api/export HTTP endpoint using NIP-98 authentication.
package main
import (
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"lol.mleku.dev/chk"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/httpauth"
"git.mleku.dev/mleku/nostr/interfaces/signer"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"next.orly.dev/pkg/version"
)
var userAgent = fmt.Sprintf("orly-export/%s", strings.TrimSpace(version.V))
func fail(format string, a ...any) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", a...)
os.Exit(1)
}
// progressWriter wraps an io.Writer and tracks bytes written and line count.
type progressWriter struct {
w io.Writer
bytes int64
lines int64
lastReport time.Time
start time.Time
}
func newProgressWriter(w io.Writer) *progressWriter {
now := time.Now()
return &progressWriter{w: w, start: now, lastReport: now}
}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
n, err = pw.w.Write(p)
pw.bytes += int64(n)
for _, b := range p[:n] {
if b == '\n' {
pw.lines++
}
}
if time.Since(pw.lastReport) >= 2*time.Second {
pw.report()
pw.lastReport = time.Now()
}
return
}
func (pw *progressWriter) report() {
elapsed := time.Since(pw.start)
mb := float64(pw.bytes) / 1024 / 1024
fmt.Fprintf(os.Stderr, "\r %d events, %.2f MB downloaded (%.1fs)",
pw.lines, mb, elapsed.Seconds())
}
func (pw *progressWriter) final() {
elapsed := time.Since(pw.start)
mb := float64(pw.bytes) / 1024 / 1024
fmt.Fprintf(os.Stderr, "\r %d events, %.2f MB downloaded in %.1fs\n",
pw.lines, mb, elapsed.Seconds())
}
func main() {
var (
relayURL string
nsec string
output string
)
flag.StringVar(&relayURL, "relay", "", "relay URL (e.g. https://plebeian.market)")
flag.StringVar(&nsec, "nsec", "", "nsec (bech32) for NIP-98 auth (or set NOSTR_SECRET_KEY)")
flag.StringVar(&output, "output", "", "output file path (default: auto-generated)")
flag.Parse()
if relayURL == "" {
fail("--relay is required (e.g. --relay https://plebeian.market)")
}
// Normalize the relay URL
relayURL = strings.TrimRight(relayURL, "/")
if !strings.HasPrefix(relayURL, "http") {
relayURL = "https://" + relayURL
}
// Get nsec from flag or env
if nsec == "" {
nsec = os.Getenv("NOSTR_SECRET_KEY")
}
var sign signer.I
if nsec != "" {
var err error
sign, err = makeSigner(nsec)
if err != nil {
fail("failed to initialize signer: %s", err)
}
fmt.Fprintf(os.Stderr, "authenticated with NIP-98\n")
} else {
fmt.Fprintf(os.Stderr, "warning: no nsec provided, attempting unauthenticated export\n")
}
// Build export URL
exportURL := relayURL + "/api/export"
ur, err := url.Parse(exportURL)
if err != nil {
fail("invalid URL: %s", err)
}
// Determine output file
if output == "" {
host := ur.Hostname()
host = strings.ReplaceAll(host, ".", "-")
output = fmt.Sprintf("export-%s-%s.jsonl",
host,
time.Now().UTC().Format("20060102-150405Z"))
}
fmt.Fprintf(os.Stderr, "exporting from %s -> %s\n", exportURL, output)
// Create HTTP request
req, err := http.NewRequest("GET", exportURL, nil)
if err != nil {
fail("failed to create request: %s", err)
}
req.Header.Set("User-Agent", userAgent)
if sign != nil {
if err = httpauth.AddNIP98Header(req, ur, "GET", "", sign, 0); chk.E(err) {
fail("failed to add NIP-98 header: %s", err)
}
}
// Execute request with no timeout (export can be large)
client := &http.Client{
Timeout: 0,
}
resp, err := client.Do(req)
if err != nil {
fail("request failed: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
fail("server returned %d: %s", resp.StatusCode, string(body))
}
// Open output file
f, err := os.Create(output)
if err != nil {
fail("failed to create output file: %s", err)
}
defer f.Close()
// Stream response to file with progress tracking
pw := newProgressWriter(f)
if _, err = io.Copy(pw, resp.Body); err != nil {
fmt.Fprintln(os.Stderr)
fail("download error: %s", err)
}
pw.final()
fmt.Fprintf(os.Stderr, "export saved to %s\n", output)
}
func makeSigner(nsec string) (signer.I, error) {
sk, err := bech32encoding.NsecToBytes([]byte(nsec))
if err != nil {
return nil, fmt.Errorf("failed to decode nsec: %w", err)
}
s, err := p8k.New()
if err != nil {
return nil, fmt.Errorf("failed to create signer: %w", err)
}
if err = s.InitSec(sk); err != nil {
return nil, fmt.Errorf("failed to init signer: %w", err)
}
return s, nil
}