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