main.go raw

   1  // Package main is a simple implementation of a cURL like tool that can do
   2  // simple GET/POST operations on a HTTP server that understands NIP-98
   3  // authentication, with the signing key found in an environment variable.
   4  package main
   5  
   6  import (
   7  	"crypto/sha256"
   8  	"fmt"
   9  	"io"
  10  	"net/http"
  11  	"net/url"
  12  	"os"
  13  	"strings"
  14  
  15  	"next.orly.dev/pkg/lol/chk"
  16  	"next.orly.dev/pkg/lol/log"
  17  
  18  	"next.orly.dev/pkg/nostr/encoders/bech32encoding"
  19  	"next.orly.dev/pkg/nostr/encoders/hex"
  20  	"next.orly.dev/pkg/nostr/httpauth"
  21  	"next.orly.dev/pkg/nostr/interfaces/signer"
  22  	"next.orly.dev/pkg/nostr/interfaces/signer/p8k"
  23  
  24  	"next.orly.dev/pkg/version"
  25  )
  26  
  27  const secEnv = "NOSTR_SECRET_KEY"
  28  
  29  var userAgent = fmt.Sprintf("nurl/%s", strings.TrimSpace(version.V))
  30  
  31  func fail(format string, a ...any) {
  32  	_, _ = fmt.Fprintf(os.Stderr, format+"\n", a...)
  33  	os.Exit(1)
  34  }
  35  
  36  func main() {
  37  	if len(os.Args) > 1 && os.Args[1] == "help" {
  38  		fmt.Printf(
  39  			`nurl help:
  40  
  41  for nostr http using NIP-98 HTTP authentication:
  42  
  43      nurl <url> [file]
  44  
  45      if no file is given, the request will be processed as a HTTP GET.
  46  
  47      * NIP-98 secret will be expected in the environment variable "%s"
  48        - if absent, will not be added to the header.
  49        - endpoint is assumed to not require it if absent.
  50        - an error will be returned if it was needed.
  51  
  52      output will be rendered to stdout
  53  
  54  `, secEnv,
  55  		)
  56  		os.Exit(0)
  57  	}
  58  	if len(os.Args) < 2 {
  59  		fail(
  60  			`error: nurl requires minimum 1 arg: <url>
  61  
  62      signing nsec (in bech32 format) is expected to be found in %s environment variable.
  63  
  64      use "help" to get usage information
  65  `, secEnv,
  66  		)
  67  	}
  68  	var err error
  69  	var sign signer.I
  70  	if sign, err = GetNIP98Signer(); err != nil {
  71  		log.W.Ln("no signer available:", err)
  72  	}
  73  	var ur *url.URL
  74  	if ur, err = url.Parse(os.Args[1]); chk.E(err) {
  75  		fail("invalid URL: `%s` error: `%s`", os.Args[1], err.Error())
  76  	}
  77  	log.T.S(ur)
  78  	if len(os.Args) == 2 {
  79  		if err = Get(ur, sign); chk.E(err) {
  80  			fail(err.Error())
  81  		}
  82  		return
  83  	}
  84  	if err = Post(os.Args[2], ur, sign); chk.E(err) {
  85  		fail(err.Error())
  86  	}
  87  }
  88  
  89  func GetNIP98Signer() (sign signer.I, err error) {
  90  	nsec := os.Getenv(secEnv)
  91  	var sk []byte
  92  	if len(nsec) == 0 {
  93  		err = fmt.Errorf("no bech32 secret key found in environment variable %s", secEnv)
  94  		return
  95  	} else if sk, err = bech32encoding.NsecToBytes([]byte(nsec)); chk.E(err) {
  96  		err = fmt.Errorf("failed to decode nsec: '%s'", err.Error())
  97  		return
  98  	}
  99  	var s *p8k.Signer
 100  	if s, err = p8k.New(); chk.E(err) {
 101  		err = fmt.Errorf("failed to create signer: '%s'", err.Error())
 102  		return
 103  	}
 104  	if err = s.InitSec(sk); chk.E(err) {
 105  		err = fmt.Errorf("failed to init signer: '%s'", err.Error())
 106  		return
 107  	}
 108  	sign = s
 109  	return
 110  }
 111  
 112  func Get(ur *url.URL, sign signer.I) (err error) {
 113  	log.T.F("GET %s", ur.String())
 114  	var r *http.Request
 115  	if r, err = http.NewRequest("GET", ur.String(), nil); chk.E(err) {
 116  		return
 117  	}
 118  	r.Header.Add("User-Agent", userAgent)
 119  	if sign != nil {
 120  		if err = httpauth.AddNIP98Header(
 121  			r, ur, "GET", "", sign, 0,
 122  		); chk.E(err) {
 123  			fail(err.Error())
 124  		}
 125  	}
 126  	client := &http.Client{
 127  		CheckRedirect: func(
 128  			req *http.Request,
 129  			via []*http.Request,
 130  		) error {
 131  			return http.ErrUseLastResponse
 132  		},
 133  	}
 134  	var res *http.Response
 135  	if res, err = client.Do(r); chk.E(err) {
 136  		err = fmt.Errorf("request failed: %w", err)
 137  		return
 138  	}
 139  	if _, err = io.Copy(os.Stdout, res.Body); chk.E(err) {
 140  		res.Body.Close()
 141  		return
 142  	}
 143  	res.Body.Close()
 144  	return
 145  }
 146  
 147  func Post(f string, ur *url.URL, sign signer.I) (err error) {
 148  	log.T.F("POST %s", ur.String())
 149  	var contentLength int64
 150  	var payload io.ReadCloser
 151  	// get the file path parameters and optional hash
 152  	var fi os.FileInfo
 153  	if fi, err = os.Stat(f); chk.E(err) {
 154  		return
 155  	}
 156  	var b []byte
 157  	if b, err = os.ReadFile(f); chk.E(err) {
 158  		return
 159  	}
 160  	hb := sha256.Sum256(b)
 161  	h := hex.Enc(hb[:])
 162  	contentLength = fi.Size()
 163  	if payload, err = os.Open(f); chk.E(err) {
 164  		return
 165  	}
 166  	log.T.F("opened file %s hash %s", f, h)
 167  	var r *http.Request
 168  	r = &http.Request{
 169  		Method:        "POST",
 170  		URL:           ur,
 171  		Proto:         "HTTP/1.1",
 172  		ProtoMajor:    1,
 173  		ProtoMinor:    1,
 174  		Header:        make(http.Header),
 175  		Body:          payload,
 176  		ContentLength: contentLength,
 177  		Host:          ur.Host,
 178  	}
 179  	r.Header.Add("User-Agent", userAgent)
 180  	if sign != nil {
 181  		if err = httpauth.AddNIP98Header(
 182  			r, ur, "POST", string(h), sign, 0,
 183  		); chk.E(err) {
 184  			fail(err.Error())
 185  		}
 186  	}
 187  	r.GetBody = func() (rc io.ReadCloser, err error) {
 188  		rc = payload
 189  		return
 190  	}
 191  	client := &http.Client{}
 192  	var res *http.Response
 193  	if res, err = client.Do(r); chk.E(err) {
 194  		return
 195  	}
 196  	defer res.Body.Close()
 197  	if _, err = io.Copy(os.Stdout, res.Body); chk.E(err) {
 198  		return
 199  	}
 200  	return
 201  }
 202