Some checks failed
Go / build-and-release (push) Has been cancelled
- Add nurl: NIP-98 authenticated HTTP client for testing owner APIs - Add vainstr: vanity npub generator using fast secp256k1 library - Update CLAUDE.md with documentation for both tools - Properly handle secp256k1 library loading via p8k.New() Files modified: - cmd/nurl/main.go: New NIP-98 HTTP client tool - cmd/vainstr/main.go: New vanity npub generator - CLAUDE.md: Added usage documentation for nurl and vainstr - go.mod/go.sum: Added go-arg dependency for vainstr - pkg/version/version: Bump to v0.39.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
202 lines
4.5 KiB
Go
202 lines
4.5 KiB
Go
// Package main is a simple implementation of a cURL like tool that can do
|
|
// simple GET/POST operations on a HTTP server that understands NIP-98
|
|
// authentication, with the signing key found in an environment variable.
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"lol.mleku.dev/chk"
|
|
"lol.mleku.dev/log"
|
|
|
|
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
|
|
"git.mleku.dev/mleku/nostr/encoders/hex"
|
|
"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"
|
|
)
|
|
|
|
const secEnv = "NOSTR_SECRET_KEY"
|
|
|
|
var userAgent = fmt.Sprintf("nurl/%s", strings.TrimSpace(version.V))
|
|
|
|
func fail(format string, a ...any) {
|
|
_, _ = fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) > 1 && os.Args[1] == "help" {
|
|
fmt.Printf(
|
|
`nurl help:
|
|
|
|
for nostr http using NIP-98 HTTP authentication:
|
|
|
|
nurl <url> [file]
|
|
|
|
if no file is given, the request will be processed as a HTTP GET.
|
|
|
|
* NIP-98 secret will be expected in the environment variable "%s"
|
|
- if absent, will not be added to the header.
|
|
- endpoint is assumed to not require it if absent.
|
|
- an error will be returned if it was needed.
|
|
|
|
output will be rendered to stdout
|
|
|
|
`, secEnv,
|
|
)
|
|
os.Exit(0)
|
|
}
|
|
if len(os.Args) < 2 {
|
|
fail(
|
|
`error: nurl requires minimum 1 arg: <url>
|
|
|
|
signing nsec (in bech32 format) is expected to be found in %s environment variable.
|
|
|
|
use "help" to get usage information
|
|
`, secEnv,
|
|
)
|
|
}
|
|
var err error
|
|
var sign signer.I
|
|
if sign, err = GetNIP98Signer(); err != nil {
|
|
log.W.Ln("no signer available:", err)
|
|
}
|
|
var ur *url.URL
|
|
if ur, err = url.Parse(os.Args[1]); chk.E(err) {
|
|
fail("invalid URL: `%s` error: `%s`", os.Args[1], err.Error())
|
|
}
|
|
log.T.S(ur)
|
|
if len(os.Args) == 2 {
|
|
if err = Get(ur, sign); chk.E(err) {
|
|
fail(err.Error())
|
|
}
|
|
return
|
|
}
|
|
if err = Post(os.Args[2], ur, sign); chk.E(err) {
|
|
fail(err.Error())
|
|
}
|
|
}
|
|
|
|
func GetNIP98Signer() (sign signer.I, err error) {
|
|
nsec := os.Getenv(secEnv)
|
|
var sk []byte
|
|
if len(nsec) == 0 {
|
|
err = fmt.Errorf("no bech32 secret key found in environment variable %s", secEnv)
|
|
return
|
|
} else if sk, err = bech32encoding.NsecToBytes([]byte(nsec)); chk.E(err) {
|
|
err = fmt.Errorf("failed to decode nsec: '%s'", err.Error())
|
|
return
|
|
}
|
|
var s *p8k.Signer
|
|
if s, err = p8k.New(); chk.E(err) {
|
|
err = fmt.Errorf("failed to create signer: '%s'", err.Error())
|
|
return
|
|
}
|
|
if err = s.InitSec(sk); chk.E(err) {
|
|
err = fmt.Errorf("failed to init signer: '%s'", err.Error())
|
|
return
|
|
}
|
|
sign = s
|
|
return
|
|
}
|
|
|
|
func Get(ur *url.URL, sign signer.I) (err error) {
|
|
log.T.F("GET %s", ur.String())
|
|
var r *http.Request
|
|
if r, err = http.NewRequest("GET", ur.String(), nil); chk.E(err) {
|
|
return
|
|
}
|
|
r.Header.Add("User-Agent", userAgent)
|
|
if sign != nil {
|
|
if err = httpauth.AddNIP98Header(
|
|
r, ur, "GET", "", sign, 0,
|
|
); chk.E(err) {
|
|
fail(err.Error())
|
|
}
|
|
}
|
|
client := &http.Client{
|
|
CheckRedirect: func(
|
|
req *http.Request,
|
|
via []*http.Request,
|
|
) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
var res *http.Response
|
|
if res, err = client.Do(r); chk.E(err) {
|
|
err = fmt.Errorf("request failed: %w", err)
|
|
return
|
|
}
|
|
if _, err = io.Copy(os.Stdout, res.Body); chk.E(err) {
|
|
res.Body.Close()
|
|
return
|
|
}
|
|
res.Body.Close()
|
|
return
|
|
}
|
|
|
|
func Post(f string, ur *url.URL, sign signer.I) (err error) {
|
|
log.T.F("POST %s", ur.String())
|
|
var contentLength int64
|
|
var payload io.ReadCloser
|
|
// get the file path parameters and optional hash
|
|
var fi os.FileInfo
|
|
if fi, err = os.Stat(f); chk.E(err) {
|
|
return
|
|
}
|
|
var b []byte
|
|
if b, err = os.ReadFile(f); chk.E(err) {
|
|
return
|
|
}
|
|
hb := sha256.Sum256(b)
|
|
h := hex.Enc(hb[:])
|
|
contentLength = fi.Size()
|
|
if payload, err = os.Open(f); chk.E(err) {
|
|
return
|
|
}
|
|
log.T.F("opened file %s hash %s", f, h)
|
|
var r *http.Request
|
|
r = &http.Request{
|
|
Method: "POST",
|
|
URL: ur,
|
|
Proto: "HTTP/1.1",
|
|
ProtoMajor: 1,
|
|
ProtoMinor: 1,
|
|
Header: make(http.Header),
|
|
Body: payload,
|
|
ContentLength: contentLength,
|
|
Host: ur.Host,
|
|
}
|
|
r.Header.Add("User-Agent", userAgent)
|
|
if sign != nil {
|
|
if err = httpauth.AddNIP98Header(
|
|
r, ur, "POST", string(h), sign, 0,
|
|
); chk.E(err) {
|
|
fail(err.Error())
|
|
}
|
|
}
|
|
r.GetBody = func() (rc io.ReadCloser, err error) {
|
|
rc = payload
|
|
return
|
|
}
|
|
client := &http.Client{}
|
|
var res *http.Response
|
|
if res, err = client.Do(r); chk.E(err) {
|
|
return
|
|
}
|
|
defer res.Body.Close()
|
|
if _, err = io.Copy(os.Stdout, res.Body); chk.E(err) {
|
|
return
|
|
}
|
|
return
|
|
}
|