Compare commits

..
45 changed files with 896 additions and 3082 deletions
+3 -89
View File
@@ -107,7 +107,7 @@ ok.
### sign an event collaboratively with multiple parties using musig2
```shell
~> nak event --sec 1234 -k 'text note' -c 'hello from a combined key' --musig 2
~> nak event --sec 1234 -k 1 -c 'hello from a combined key' --musig 2
the following code should be saved secretly until the next step an included with --musig-nonce-secret:
QebOT03ERmV7km22CqEqBPFmzAkgxQzGGbR7Si8yIZCBrd1N9A3LKwGLO71kbgXZ9EYFKpjiwun4u0mj5Tq6vwM3pK7x+EI8oHbkt9majKv/QN24Ix8qnwEIHxXX+mXBug==
@@ -145,33 +145,6 @@ type the password to decrypt your secret key: **********
985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7
```
### display profile information for any npub, nip05 or hex key
```shell
~> nak profile f7z.io
pubkey (hex): fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52
npub: npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
profile uri: nostr:nprofile1qqs04xzt6ldm9qhs0ctw0t58kf4z57umjzmjg6jywu0seadwtqqc75spr9mhxue69uhhq7tjv9kkjepwve5kzar2v9nzucm0d5q3qamnwvaz7te3xsczue3h0ghxjmcppemhxue69uhhytnxxaazu6t0axr23s
name: PABLOF7z
display_name: PABLOF7z
about: Magical Other Stuff Maximalist.
picture: https://m.primal.net/KwlG.jpg
banner: https://24242.io/1dc3a4a8bb2626551d4cc91e93f5b2e6ef87b1931b9db9bf4ac72dfe61c7a45b.png
website: https://pablof7z.com
nip05: _@f7z.io (verified)
lud16: pablof7z@primal.net
relays:
wss://relay.damus.io (read)
wss://relay.primal.net (read)
wss://pyramid.fiatjaf.com (write)
wss://140.f7z.io (write)
wss://r.f7z.io (read/write)
follows: 1051
~>
~> nak profile f7z.io --name
PABLOF7z
```
### sign an event using a bunker provider (amber, promenade etc)
```shell
~> export NOSTR_CLIENT_KEY="$(nak key generate)"
@@ -311,28 +284,6 @@ type the password to decrypt your secret key: ********
~> nak req -i 412f2d3e73acc312942c055ac2a695dc60bf58ff97e06689a8a79e97796c4cdb relay.westernbtc.com | jq -r .content > ~/.jq
```
### filter and transform events with the built-in `--jq` helpers
extra-filter events with `select()` based on tags:
```shell
~> nak req -k 1 -l 10 relay.damus.io --jq 'select(has("t"))'
```
extract a tag value:
```shell
~> nak req -k 1 -l 10 -t t=gm relay.damus.io --jq 'value("t")'
```
make an event easier to read by removing fields and adding human-readable time:
```shell
~> nak req -k 1 -l 5 relay.damus.io --jq '. + { d: datetime } | del(.sig, .created_at)'
```
you can do these same things by piping to standalone `jq`, take the functions in https://github.com/fiatjaf/nak/blob/master/jq.go#L10 and add them to your `~/.jq` file.
### watch a NIP-53 livestream (zap.stream, amethyst, shosho etc)
```shell
~> # this requires the jq utils from the step above
@@ -392,7 +343,7 @@ echo "#surely you're joking, mr npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn6
### record and publish an audio note (yakbak, nostur etc) signed from a bunker
```shell
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 'voice message' --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 1222 --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
```
### gift-wrap an event to a recipient and publish it somewhere
@@ -402,7 +353,7 @@ ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blos
### download a gift-wrap event and unwrap it
```shell
~> nak req -p <my-public-key> -k 'giftwrap' relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
~> nak req -p <my-public-key> -k 1059 relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
```
### sync events between two relays using negentropy
@@ -521,40 +472,3 @@ gitnostr.com... ok.
~> nak group chat "<relay>'<id>"
~> nak group chat send "<relay>'<id>" "<message>"
```
### figure out what is a given kind
```shell
~> nak kind 10050 | jq .description
"Relay list to receive DMs"
~> nak kind 'fav relays' | jq
{
"kind": 10012,
"description": "Favorite relays list",
"in_use": true,
"content": {
"type": "free"
},
"multiple": [
"relay"
],
"tags": [
{
"name": "relay",
"next": {
"type": "relay",
"required": true
}
},
{
"name": "a",
"next": {
"type": "addr",
"required": true,
"next": {
"type": "relay"
}
}
}
]
}
```
+6 -28
View File
@@ -25,6 +25,7 @@ var admin = &cli.Command{
nak admin changerelayname myrelay.com --name "My Relay"`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: (func() []*cli.Command {
methods := []struct {
method string
@@ -47,17 +48,6 @@ var admin = &cli.Command{
{"blockip", []string{"ip", "reason"}},
{"unblockip", []string{"ip", "reason"}},
{"listblockedips", nil},
{"listallowedevents", nil},
{"listdisallowedkinds", nil},
{"supportedmethods", nil},
{"stats", nil},
{"grantadmin", []string{"pubkey", "methods"}},
{"revokeadmin", []string{"pubkey", "methods"}},
{"createrole", []string{"role_id", "label", "description", "color", "order"}},
{"editrole", []string{"role_id", "label", "description", "color", "order"}},
{"deleterole", []string{"role_id"}},
{"assignrole", []string{"pubkey", "role_id"}},
{"unassignrole", []string{"pubkey", "role_id"}},
}
commands := make([]*cli.Command, 0, len(methods))
@@ -132,7 +122,6 @@ var admin = &cli.Command{
continue
}
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
log("failed to read response: %s\n", err)
continue
@@ -143,7 +132,7 @@ var admin = &cli.Command{
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log("%s", bodyPrintable)
log(bodyPrintable)
continue
}
var response nip86.Response
@@ -153,9 +142,10 @@ var admin = &cli.Command{
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log("%s", bodyPrintable)
log(bodyPrintable)
continue
}
resp.Body.Close()
// print the result
log("\n")
@@ -177,16 +167,10 @@ var admin = &cli.Command{
func declareFlag(argName string) cli.Flag {
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
switch argName {
case "pubkey":
return &PubKeyFlag{Name: argName, Usage: usage}
case "id":
return &IDFlag{Name: argName, Usage: usage}
case "kind", "order", "color":
case "kind":
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
case "reason":
return &cli.StringFlag{Name: argName, Usage: usage}
case "methods":
return &cli.StringSliceFlag{Name: argName, Required: true, Usage: usage}
default:
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
}
@@ -194,14 +178,8 @@ func declareFlag(argName string) cli.Flag {
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "pubkey":
return getPubKey(c, argName)
case "id":
return getID(c, argName)
case "kind", "order", "color":
case "kind":
return c.Int(argName)
case "methods":
return c.StringSlice(argName)
default:
return c.String(argName)
}
+4 -9
View File
@@ -20,7 +20,7 @@ var blossomCmd = &cli.Command{
UseShortOptionHandling: true,
Usage: "an army knife for blossom things",
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
&cli.StringSliceFlag{
Name: "server",
Aliases: []string{"s"},
@@ -179,7 +179,7 @@ var blossomCmd = &cli.Command{
hasError = true
continue
}
os.Stdout.Write(data)
stdout(data)
}
}
@@ -238,11 +238,6 @@ if any of the files are not found the command will fail, otherwise it will succe
hasError := false
for _, hash := range c.Args().Slice() {
// a sha256 is 64 hex characters, anything after that is an extension
if len(hash) > 64 {
hash = hash[0:64]
}
err := client.Check(ctx, hash)
if err != nil {
hasError = true
@@ -286,7 +281,7 @@ if any of the files are not found the command will fail, otherwise it will succe
return err
}
out, _ := json.Marshal(bd)
stdout(string(out))
stdout(out)
return nil
}
@@ -305,7 +300,7 @@ if any of the files are not found the command will fail, otherwise it will succe
continue
}
out, _ := json.Marshal(bd)
stdout(string(out))
stdout(out)
}
exitIfLineProcessingError(ctx)
+116 -130
View File
@@ -13,7 +13,6 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
@@ -45,6 +44,15 @@ var bunker = &cli.Command{
OnlyOnce: true,
Category: PERSISTENCE,
},
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as hex or nsec",
DefaultText: "the key '1'",
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
},
&cli.StringSliceFlag{
Name: "authorized-secrets",
Aliases: []string{"s"},
@@ -154,10 +162,10 @@ var bunker = &cli.Command{
}
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
// we don't have any secret key stored, so just use whatever was given via flags (or defaults)
// we don't have any secret key stored, so just use whatever was given via flags
config.Secret = baseSecret
} else if !c.IsSet("sec") && !c.IsSet("prompt-sec") {
// we didn't provide any keys explicitly, so we just use the stored
} else if baseSecret.Plain == nil && baseSecret.Encrypted == nil {
// we didn't provide any keys, so we just use the stored
} else {
// we have a secret key stored
// if we also provided a key we check if they match and fail otherwise
@@ -177,7 +185,7 @@ var bunker = &cli.Command{
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
sec := os.Getenv("NOSTR_SECRET_KEY")
if sec == "" {
sec = defaultKey().Hex()
sec = defaultKey
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
@@ -217,7 +225,7 @@ var bunker = &cli.Command{
}
}
}
relays := connectToAllRelays(ctx, c, allRelays)
relays := connectToAllRelays(ctx, c, allRelays, nil)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -236,19 +244,12 @@ var bunker = &cli.Command{
// it will be stored
newSecret := randString(12)
// guards config.Clients, newSecret and cancelPreviousBunkerInfoPrint, which are
// accessed from the socket goroutine, the per-request handler goroutines and here
var mu sync.Mutex
// static information
pubkey := sec.Public()
npub := nip19.EncodeNpub(pubkey)
// this function will be called every now and then
printBunkerInfo := func() {
mu.Lock()
defer mu.Unlock()
iqs := make(url.Values)
maps.Copy(iqs, qs)
iqs.Set("secret", newSecret)
@@ -350,15 +351,70 @@ var bunker = &cli.Command{
signer := nip46.NewStaticKeySigner(sec)
signer.DefaultRelays = config.Relays
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
if persist != nil {
persist()
}
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
events <- event
}
}()
time.Sleep(time.Millisecond * 25)
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent through %s\n", res.Relay.URL)
} else {
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
}
}
}
}()
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
mu.Lock()
defer mu.Unlock()
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
@@ -394,130 +450,60 @@ var bunker = &cli.Command{
return false
}
handleBunkerRequest := func(ie nostr.RelayEvent) {
mu.Lock()
for ie := range events {
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
mu.Unlock()
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
if errors.Is(err, nip46.AlreadyHandled) {
go func() {
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
if errors.Is(err, nip46.AlreadyHandled) {
return
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
return
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
return
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := config.Relays
mu.Lock()
for _, c := range config.Clients {
if c.PubKey == from && len(c.CustomRelays) > 0 {
relays = c.CustomRelays
break
}
}
mu.Unlock()
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent response through %s\n", res.Relay.URL)
} else {
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
}
}
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
mu.Lock()
cancelPreviousBunkerInfoPrint = cancel
mu.Unlock()
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
}
}()
}
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
mu.Lock()
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
if persist != nil {
persist()
}
mu.Unlock()
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
// handle directly instead of forwarding into the main events
// channel, which is owned (and eventually closed) by the pool
go handleBunkerRequest(event)
}
}()
time.Sleep(time.Millisecond * 25)
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent through %s\n", res.Relay.URL)
} else {
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := config.Relays
for _, c := range config.Clients {
if c.PubKey == from && len(c.CustomRelays) > 0 {
relays = c.CustomRelays
break
}
}
}
}()
for ie := range events {
go handleBunkerRequest(ie)
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent response through %s\n", res.Relay.URL)
} else {
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
}
}
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cancelPreviousBunkerInfoPrint = cancel
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
}
}()
}()
}
return nil
-14
View File
@@ -229,17 +229,3 @@ func TestNaturalTimestamps(t *testing.T) {
require.Equal(t, nostr.Timestamp(1526711839), evt.CreatedAt)
require.Equal(t, "nn", evt.Content)
}
func TestEventJQ(t *testing.T) {
// default: string results come out JSON-quoted, like plain jq
quoted := call(t, "nak event --ts 1699485669 -c hello --jq .content")
require.Equal(t, `"hello"`, quoted)
// --jq-raw: string results come out unquoted, like `jq -r`
raw := call(t, "nak event --ts 1699485669 -c hello --jq .content --jq-raw")
require.Equal(t, "hello", raw)
// --jq-raw on a non-string result is still JSON-encoded, like `jq -r`
num := call(t, "nak event --ts 1699485669 -k 7 --jq .kind --jq-raw")
require.Equal(t, "7", num)
}
+36 -17
View File
@@ -7,8 +7,10 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
@@ -18,16 +20,47 @@ var count = &cli.Command{
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{reqFilterFlags}),
ArgsUsage: "[relay...]",
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"COUNT\"",
Category: CATEGORY_SIGNER,
},
)...,
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
biggerUrlSize := 0
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
@@ -63,21 +96,7 @@ var count = &cli.Command{
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
nm := nostr.NormalizeURL(relayUrl)
relay, ok := sys.Pool.Relays.Load(nm)
if !ok || relay == nil || !relay.IsConnected() {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
var err error
relay, err = nostr.RelayConnect(ct, relayUrl, sys.Pool.RelayOptions)
cancel()
if err != nil {
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
fmt.Fprintf(os.Stderr, "error: %s\n", err)
continue
}
sys.Pool.Relays.Store(nm, relay)
}
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
+2 -5
View File
@@ -19,10 +19,8 @@ var curl = &cli.Command{
Name: "curl",
Usage: "calls curl but with a nip98 header",
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
Flags: defaultKeyFlags,
DisableSliceFlagSeparator: true,
// this command is always run standalone (see realCurl()), never under the root
// command, so it needs its own copy of the key flags
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
@@ -132,6 +130,5 @@ func realCurl() error {
}
}
// Run treats the first element as the program name, so prepend one
return curl.Run(context.Background(), append([]string{"nak curl"}, keyFlags...))
return curl.Run(context.Background(), keyFlags)
}
+6 -8
View File
@@ -20,7 +20,7 @@ var dekey = &cli.Command{
Usage: "handles NIP-4E decoupled encryption keys",
Description: "maybe this picture will explain better than I can do here for now: https://cdn.azzamo.net/89c543d261ad0d665c1dea78f91e527c2e39e7fe503b440265a3c47e63c9139f.png",
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "device",
Usage: "name of this device that will be published and displayed on other clients",
@@ -87,7 +87,7 @@ var dekey = &cli.Command{
// get relays for the user
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
relays := sys.FetchWriteRelays(ctx, userPub)
relayList := connectToAllRelays(ctx, c, relays)
relayList := connectToAllRelays(ctx, c, relays, nil)
if len(relayList) == 0 {
return fmt.Errorf("no relays to use")
}
@@ -164,7 +164,7 @@ var dekey = &cli.Command{
return fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return fmt.Errorf("stored decoupled encryption key at %s doesn't match the announced key %s", eKeyPath, ePub.Hex())
return fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
}
} else {
log("- decoupled encryption key not found locally, attempting to fetch the key from other devices\n")
@@ -236,14 +236,12 @@ var dekey = &cli.Command{
if err != nil {
continue
}
candidate, err := nostr.SecretKeyFromHex(eSecHex)
eSec, err = nostr.SecretKeyFromHex(eSecHex)
if err != nil {
continue
}
// check if it matches mainPub -- only keep it if it does, otherwise a
// stale key received from another device would end up being redistributed
if candidate.Public() == ePub {
eSec = candidate
// check if it matches mainPub
if eSec.Public() == ePub {
log(color.GreenString("successfully received decoupled encryption key from another device\n"))
// store it
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
+2 -13
View File
@@ -8,7 +8,6 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
@@ -291,7 +290,7 @@ var encode = &cli.Command{
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
},
&KindFlag{
&cli.IntFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
@@ -306,22 +305,12 @@ var encode = &cli.Command{
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getEncodeSubcommandInput(c.Args(), true) {
pubkey := getPubKey(c, "pubkey")
kind := getKind(c, "kind")
kind := nostr.Kind(c.Int("kind"))
d := c.String("identifier")
relays := c.StringSlice("relay")
+6 -4
View File
@@ -13,7 +13,8 @@ var encrypt = &cli.Command{
Usage: "encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64",
ArgsUsage: "[plaintext string]",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
Flags: append(
defaultKeyFlags,
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
@@ -23,7 +24,7 @@ var encrypt = &cli.Command{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
},
),
Action: func(ctx context.Context, c *cli.Command) error {
target := getPubKey(c, "recipient-pubkey")
@@ -74,7 +75,8 @@ var decrypt = &cli.Command{
Usage: "decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext",
ArgsUsage: "[ciphertext base64]",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
Flags: append(
defaultKeyFlags,
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey", "from"},
@@ -84,7 +86,7 @@ var decrypt = &cli.Command{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
},
),
Action: func(ctx context.Context, c *cli.Command) error {
source := getPubKey(c, "sender-pubkey")
+23 -65
View File
@@ -13,7 +13,6 @@ import (
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip13"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
@@ -23,7 +22,6 @@ const (
CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
CATEGORY_SIGNER = "SIGNER OPTIONS"
CATEGORY_EXTRAS = "EXTRAS"
CATEGORY_AUTH = "AUTH"
)
var eventCmd = &cli.Command{
@@ -41,7 +39,7 @@ example:
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak event wss://offchain.pub
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
// ~ these args are only for the convoluted musig2 signing process
// they will be generally copy-shared-pasted across some manual coordination method between participants
&cli.UintFlag{
@@ -90,14 +88,9 @@ example:
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
Category: CATEGORY_EXTRAS,
},
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
@@ -111,10 +104,10 @@ example:
DefaultText: "false, will only use manually-specified relays",
Category: CATEGORY_EXTRAS,
},
&KindFlag{
&cli.UintFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "event kind number or name",
Usage: "event kind",
DefaultText: "1",
Value: 0,
Category: CATEGORY_EVENT_FIELDS,
@@ -172,31 +165,16 @@ example:
Usage: "ask before publishing the event",
Category: CATEGORY_EXTRAS,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
argRelayUrls := c.Args().Slice()
relayUrls := c.Args().Slice()
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
// then process input and generate events:
// will reuse this
@@ -206,7 +184,7 @@ example:
handleEvent := func(stdinEvent string) error {
evt.Content = ""
evt.CreatedAt = 0
evt.Tags = evt.Tags[:0]
clear(evt.Tags)
evt.ID = nostr.ZeroID
evt.PubKey = nostr.ZeroPK
evt.Sig = [64]byte{}
@@ -219,8 +197,8 @@ example:
return fmt.Errorf("invalid event received from stdin: %s", err)
}
if c.IsSet("kind") {
evt.Kind = getKind(c, "kind")
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
evt.Kind = nostr.Kind(kind)
mustRehashAndResign = true
} else if !kindWasSupplied {
evt.Kind = 1
@@ -383,9 +361,6 @@ example:
}
var relays []*nostr.Relay
// start from the given relays on every event so --outbox additions
// for one event don't leak into the next
relayUrls := slices.Clone(argRelayUrls)
if len(relayUrls) > 0 || c.Bool("outbox") {
if c.Bool("outbox") {
if evt.PubKey != nostr.ZeroPK {
@@ -410,7 +385,10 @@ example:
}
if len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, c, relayUrls)
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays = connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -419,25 +397,15 @@ example:
}
// print event as json
if jq == nil {
var result string
if c.Bool("envelope") {
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
result = string(j)
} else {
j, _ := easyjson.Marshal(&evt)
result = string(j)
}
stdout(result)
var result string
if c.Bool("envelope") {
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
result = string(j)
} else {
out, matches, err := jq(evt)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if matches {
stdout(out)
}
j, _ := easyjson.Marshal(&evt)
result = string(j)
}
stdout(result)
return publishFlow(ctx, c, kr, evt, relays)
}
@@ -543,19 +511,9 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
defer cancel()
if !relay.IsConnected() {
nm := nostr.NormalizeURL(relay.URL)
if r, ok := sys.Pool.Relays.Load(nm); ok && r != nil && r.IsConnected() {
relays[i] = r
relay = r
} else {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
new_, err := nostr.RelayConnect(ct, relay.URL, sys.Pool.RelayOptions)
cancel()
if err == nil {
sys.Pool.Relays.Store(nm, new_)
relays[i] = new_
relay = new_
}
if new_, err := sys.Pool.EnsureRelay(relay.URL); err == nil {
relays[i] = new_
relay = new_
}
}
+31 -35
View File
@@ -3,11 +3,14 @@ package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk/hints"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -18,28 +21,21 @@ var fetch = &cli.Command{
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
},
&cli.BoolFlag{
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
},
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
)...,
),
ArgsUsage: "[nip05_or_nip19_code]",
Action: func(ctx context.Context, c *cli.Command) error {
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
for code := range getStdinLinesOrArguments(c.Args()) {
filter := nostr.Filter{}
var authorHint nostr.PubKey
@@ -74,7 +70,7 @@ var fetch = &cli.Command{
}
relays = append(relays, v.Relays...)
case "note":
filter.IDs = append(filter.IDs, value.(nostr.EventPointer).ID)
filter.IDs = append(filter.IDs, value.([32]byte))
case "naddr":
v := value.(nostr.EntityPointer)
filter.Kinds = []nostr.Kind{v.Kind}
@@ -101,7 +97,7 @@ var fetch = &cli.Command{
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
}
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 6) {
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 3) {
relays = append(relays, url)
}
}
@@ -119,25 +115,25 @@ var fetch = &cli.Command{
continue
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
found := false
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-fetch",
}) {
found = true
var out string
if jq == nil {
out = ie.Event.String()
} else {
v, matches, err := jq(ie.Event)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if !matches {
continue
}
out = v
}
stdout(out)
stdout(ie.Event)
}
if !found {
+3 -10
View File
@@ -19,15 +19,11 @@ example:
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
Flags: append(append([]cli.Flag{}, reqFilterFlags...),
&cli.StringFlag{
Name: "jq",
Usage: "filter matching events with jq expression",
},
&cli.BoolFlag{
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
},
),
ArgsUsage: "[event_json] [base_filter_json]",
Action: func(ctx context.Context, c *cli.Command) error {
@@ -63,7 +59,7 @@ example:
return err
}
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
jq, err := jqPrepare(c.String("jq"))
if err != nil {
return err
}
@@ -86,9 +82,6 @@ example:
if evt.Sig == [64]byte{} {
evt.Sig = baseEvent.Sig
}
if evt.Kind == 0 {
evt.Kind = baseEvent.Kind
}
if evt.Content == "" {
evt.Content = baseEvent.Content
}
@@ -111,7 +104,7 @@ example:
if !matches {
continue
}
out = v
out, _ = json.MarshalToString(v)
}
stdout(out)
} else {
-91
View File
@@ -131,45 +131,6 @@ func getNaturalDate(cmd *cli.Command, name string) nostr.Timestamp {
//
//
type (
SecretKeyFlag = cli.FlagBase[nostr.SecretKey, struct{}, secretkeyValue]
)
type secretkeyValue struct {
secretkey nostr.SecretKey
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.SecretKey, struct{}] = secretkeyValue{}
func (t secretkeyValue) Create(val nostr.SecretKey, p *nostr.SecretKey, c struct{}) cli.Value {
*p = val
return &secretkeyValue{
secretkey: val,
}
}
func (t secretkeyValue) ToString(b nostr.SecretKey) string { return t.secretkey.String() }
func (t *secretkeyValue) Set(value string) error {
secretkey, err := parseSecretKey(value)
t.secretkey = secretkey
t.hasBeenSet = true
return err
}
func (t *secretkeyValue) String() string { return fmt.Sprintf("%#v", t.secretkey) }
func (t *secretkeyValue) Value() nostr.SecretKey { return t.secretkey }
func (t *secretkeyValue) Get() any { return t.secretkey }
func getSecretKey(cmd *cli.Command, name string) nostr.SecretKey {
return cmd.Value(name).(nostr.SecretKey)
}
//
//
//
type (
PubKeyFlag = cli.FlagBase[nostr.PubKey, struct{}, pubkeyValue]
)
@@ -328,55 +289,3 @@ type (
func getIDSlice(cmd *cli.Command, name string) []nostr.ID {
return cmd.Value(name).([]nostr.ID)
}
//
//
//
type (
KindFlag = cli.FlagBase[nostr.Kind, struct{}, kindValue]
)
type kindValue struct {
kind nostr.Kind
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.Kind, struct{}] = kindValue{}
func (t kindValue) Create(val nostr.Kind, p *nostr.Kind, c struct{}) cli.Value {
*p = val
return &kindValue{
kind: val,
}
}
func (t kindValue) ToString(b nostr.Kind) string { return fmt.Sprintf("%d", b) }
func (t *kindValue) Set(value string) error {
k, err := stringToKind(value)
t.kind = k
t.hasBeenSet = true
return err
}
func (t *kindValue) String() string { return fmt.Sprintf("%#v", t.kind) }
func (t *kindValue) Value() nostr.Kind { return t.kind }
func (t *kindValue) Get() any { return t.kind }
func getKind(cmd *cli.Command, name string) nostr.Kind {
return cmd.Value(name).(nostr.Kind)
}
//
//
//
type (
kindSlice = cli.SliceBase[nostr.Kind, struct{}, kindValue]
KindSliceFlag = cli.FlagBase[[]nostr.Kind, struct{}, kindSlice]
)
func getKindSlice(cmd *cli.Command, name string) []nostr.Kind {
return cmd.Value(name).([]nostr.Kind)
}
+1 -1
View File
@@ -24,7 +24,7 @@ var fsCmd = &cli.Command{
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
+1 -1
View File
@@ -23,7 +23,7 @@ var fsCmd = &cli.Command{
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
+1 -1
View File
@@ -22,7 +22,7 @@ var fsCmd = &cli.Command{
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
+2 -1
View File
@@ -24,6 +24,7 @@ var gift = &cli.Command{
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "wrap",
@@ -332,7 +333,7 @@ func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pub
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key at %s doesn't match the announced key %s", eKeyPath, ePub.Hex())
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
}
return eSec, true, nil
}
+209 -907
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -29,10 +29,8 @@ require (
require (
fiatjaf.com/lib v0.3.7
github.com/denisbrodbeck/machineid v1.0.1
github.com/hanwen/go-fuse/v2 v2.9.0
github.com/itchyny/gojq v0.12.19
github.com/lithammer/fuzzysearch v1.1.8
github.com/mattn/go-tty/v2 v2.0.0
)
+2 -14
View File
@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e h1:/vgoytiH4qQ28O/oX1hj656X69CnTy1pGj/17HFjH+A=
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86 h1:p3HnX1UDT/CfiMvTc4yTcxHQm08ri7DM32P1uKkFNKg=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
@@ -102,8 +102,6 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
@@ -183,8 +181,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
@@ -304,7 +300,6 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -313,13 +308,11 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -336,13 +329,11 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -350,14 +341,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+36 -87
View File
@@ -16,6 +16,7 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip29"
"fiatjaf.com/nostr/nip42"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -27,8 +28,26 @@ var group = &cli.Command{
Description: `manage and interact with Nostr communities (NIP-29). Use "nak group <subcommand> <relay>'<identifier>" where host.tld is the relay and identifier is the group identifier.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "<subcommand> <relay>'<identifier> [flags]",
Flags: combineFlags([][]cli.Flag{}),
Flags: append(defaultKeyFlags,
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
),
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
return ctx, nil
},
Commands: []*cli.Command{
@@ -243,17 +262,9 @@ var group = &cli.Command{
return err
}
nm := nostr.NormalizeURL(relay)
r, ok := sys.Pool.Relays.Load(nm)
if !ok || r == nil || !r.IsConnected() {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
var err error
r, err = nostr.RelayConnect(ct, relay, sys.Pool.RelayOptions)
cancel()
if err != nil {
return err
}
sys.Pool.Relays.Store(nm, r)
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
sub, err := r.Subscribe(ctx, nostr.Filter{
@@ -324,19 +335,11 @@ var group = &cli.Command{
return fmt.Errorf("failed to sign message: %w", err)
}
nm := nostr.NormalizeURL(relay)
r, ok := sys.Pool.Relays.Load(nm)
if !ok || r == nil || !r.IsConnected() {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
var err error
r, err = nostr.RelayConnect(ct, relay, sys.Pool.RelayOptions)
cancel()
if err != nil {
return err
}
sys.Pool.Relays.Store(nm, r)
if r, err := sys.Pool.EnsureRelay(relay); err != nil {
return err
} else {
return r.Publish(ctx, msg)
}
return r.Publish(ctx, msg)
},
},
},
@@ -490,17 +493,9 @@ write your forum post
return fmt.Errorf("failed to sign forum topic event: %w", err)
}
nm := nostr.NormalizeURL(relay)
r, ok := sys.Pool.Relays.Load(nm)
if !ok || r == nil || !r.IsConnected() {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
var err error
r, err = nostr.RelayConnect(ct, relay, sys.Pool.RelayOptions)
cancel()
if err != nil {
return err
}
sys.Pool.Relays.Store(nm, r)
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
return r.Publish(ctx, evt)
@@ -605,17 +600,9 @@ write your forum post
return fmt.Errorf("failed to sign forum comment event: %w", err)
}
nm := nostr.NormalizeURL(relay)
r, ok := sys.Pool.Relays.Load(nm)
if !ok || r == nil || !r.IsConnected() {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
var err error
r, err = nostr.RelayConnect(ct, relay, sys.Pool.RelayOptions)
cancel()
if err != nil {
return err
}
sys.Pool.Relays.Store(nm, r)
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
return r.Publish(ctx, evt)
@@ -727,18 +714,6 @@ write your forum post
Name: "all-kinds",
Usage: "specify this to delete the supported_kinds property, meaning everything will be supported",
},
&cli.StringFlag{
Name: "parent",
Usage: "set parent group identifier",
},
&cli.StringFlag{
Name: "children",
Usage: "set child group identifiers (semicolon-separated)",
},
&cli.BoolFlag{
Name: "orphan",
Usage: "remove parent tag, making this group an orphan",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
@@ -803,18 +778,6 @@ write your forum post
} else if c.Bool("all-kinds") {
group.SupportedKinds = nil
}
if parent := c.String("parent"); parent != "" {
group.Parent = parent
}
if children := c.String("children"); children != "" {
group.Children = strings.Split(children, ";")
for i := range group.Children {
group.Children[i] = strings.TrimSpace(group.Children[i])
}
}
if c.Bool("orphan") {
group.Parent = ""
}
return publishModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
evt.Tags = append(evt.Tags, nostr.Tag{"name", group.Name})
@@ -845,12 +808,6 @@ write your forum post
}
evt.Tags = append(evt.Tags, tag)
}
if group.Parent != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"parent", group.Parent})
}
for _, child := range group.Children {
evt.Tags = append(evt.Tags, nostr.Tag{"child", child})
}
return nil
})
},
@@ -938,17 +895,9 @@ func publishModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind
stdout(evt.String())
nm := nostr.NormalizeURL(relay)
r, ok := sys.Pool.Relays.Load(nm)
if !ok || r == nil || !r.IsConnected() {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
var err error
r, err = nostr.RelayConnect(ct, relay, sys.Pool.RelayOptions)
cancel()
if err != nil {
return err
}
sys.Pool.Relays.Store(nm, r)
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
return r.Publish(ctx, evt)
+73 -160
View File
@@ -13,7 +13,6 @@ import (
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -22,12 +21,11 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk"
"github.com/chzyer/readline"
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/mattn/go-isatty"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
@@ -48,8 +46,6 @@ var (
stdout = func(args ...any) { fmt.Fprintln(color.Output, args...) }
)
var connectTimeout = 2 * time.Second
func isPiped() bool {
stat, err := os.Stdin.Stat()
if err != nil {
@@ -189,6 +185,7 @@ func connectToAllRelays(
ctx context.Context,
c *cli.Command,
relayUrls []string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error), // if this exists we will force preauth
) []*nostr.Relay {
// first pass to check if these are valid relay URLs
for i, url := range relayUrls {
@@ -221,28 +218,21 @@ func connectToAllRelays(
wg := sync.WaitGroup{}
wg.Add(len(relayUrls))
mu := sync.Mutex{} // guards lines and relays, written from the goroutines below
for i, url := range relayUrls {
lines[i] = make([][]byte, 1, 2)
logthis := func(s string, args ...any) {
mu.Lock()
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
render()
mu.Unlock()
}
colorizepreamble := func(c func(string, ...any) string) {
mu.Lock()
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
mu.Unlock()
}
colorizepreamble(color.CyanString)
go func() {
relay := connectToSingleRelay(ctx, c, url, colorizepreamble, logthis)
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, colorizepreamble, logthis)
if relay != nil {
mu.Lock()
relays = append(relays, relay)
mu.Unlock()
}
wg.Done()
}()
@@ -252,7 +242,7 @@ func connectToAllRelays(
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
relay := connectToSingleRelay(ctx, c, url, nil, log)
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
if relay != nil {
relays = append(relays, relay)
}
@@ -267,78 +257,67 @@ func connectToSingleRelay(
ctx context.Context,
c *cli.Command,
url string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error),
colorizepreamble func(c func(string, ...any) string),
logthis func(s string, args ...any),
) *nostr.Relay {
nm := nostr.NormalizeURL(url)
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
if preAuthSigner != nil {
if colorizepreamble != nil {
colorizepreamble(color.YellowString)
}
logthis("waiting for auth challenge... ")
time.Sleep(time.Millisecond * 200)
relay, ok := sys.Pool.Relays.Load(nm)
if !ok || relay == nil || !relay.IsConnected() {
connectCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
defer cancel()
var err error
if relay, err = nostr.RelayConnect(connectCtx, url, sys.Pool.RelayOptions); err != nil {
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
}); err == nil {
// auth succeeded
goto preauthSuccess
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second)
continue
} else {
// it failed for some other reason, so skip this relay
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis(err.Error())
return nil
}
}
}
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis(clampError(err, len(url)+12))
logthis("failed to get an AUTH challenge in enough time.")
return nil
}
sys.Pool.Relays.Store(nm, relay)
go func(r *nostr.Relay, relayURL string) {
<-r.Context().Done()
if current, ok := sys.Pool.Relays.Load(relayURL); ok && current == r {
sys.Pool.Relays.Delete(relayURL)
}
}(relay, nm)
}
if c.Bool("force-pre-auth") {
preauthSuccess:
if colorizepreamble != nil {
colorizepreamble(color.YellowString)
}
logthis("waiting for auth challenge... ")
time.Sleep(time.Millisecond * 200)
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return authSigner(ctx, c, logthis, authEvent)
}); err == nil {
// auth succeeded
goto preauthSuccess
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
time.Sleep(time.Second)
continue
} else {
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis(err.Error())
return nil
}
}
colorizepreamble(colors.successf)
}
logthis("ok.")
return relay
} else {
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis("failed to get an AUTH challenge in enough time.")
// if we're here that means we've failed to connect, this may be a huge message
// but we're likely to only be interested in the lowest level error (although we can leave space)
logthis(clampError(err, len(url)+12))
return nil
}
preauthSuccess:
if colorizepreamble != nil {
colorizepreamble(colors.successf)
}
logthis("ok.")
return relay
}
func clearLines(lineCount int) {
@@ -370,6 +349,29 @@ func supportsDynamicMultilineMagic() bool {
return true
}
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
defer func() {
if err != nil {
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
log("%s auth failed: %s", colors.errorf(cleanUrl), err)
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth required, but --auth flag not given")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
return kr.SignEvent(ctx, authEvent)
}
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
log(msg+"\n", args...)
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
@@ -499,19 +501,6 @@ func parsePubKey(value string) (nostr.PubKey, error) {
return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value)
}
func parseSecretKey(input string) (nostr.SecretKey, error) {
if prefix, ski, err := nip19.Decode(input); err == nil && prefix == "nsec" {
return ski.(nostr.SecretKey), nil
}
sk, err := nostr.SecretKeyFromHex(input)
if err != nil {
return nostr.SecretKey{}, fmt.Errorf("invalid secret key: %w", err)
}
return sk, nil
}
func parseEventID(value string) (nostr.ID, error) {
id, err := nostr.IDFromHex(value)
if err == nil {
@@ -521,8 +510,8 @@ func parseEventID(value string) (nostr.ID, error) {
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "note":
if event, ok := decoded.(nostr.EventPointer); ok {
return event.ID, nil
if id, ok := decoded.(nostr.ID); ok {
return id, nil
}
case "nevent":
if event, ok := decoded.(nostr.EventPointer); ok {
@@ -621,68 +610,6 @@ func clampWithEllipsis(s string, size int) string {
return s[0:size-1] + "…"
}
var (
schemaURI string
fetchSchemaOnce sync.Once
schemaCache schema.Schema
schemaErrCache error
)
func getSchema() (schema.Schema, error) {
fetchSchemaOnce.Do(func() {
if strings.HasPrefix(schemaURI, "http") {
schemaCache, schemaErrCache = schema.FetchSchemaFromURL(schemaURI)
} else {
schemaCache, schemaErrCache = schema.NewSchemaFromFile(schemaURI)
}
})
return schemaCache, schemaErrCache
}
func stringToKind(value string) (nostr.Kind, error) {
if n, err := strconv.Atoi(value); err == nil && n >= 0 {
if n > 65535 {
return 0, fmt.Errorf("kind number %d is out of range (0-65535)", n)
}
return nostr.Kind(n), nil
}
// find kind from name
sch, err := getSchema()
if err != nil {
return 0, err
}
fuzzyWords := make([]string, 0, len(sch.Kinds))
fuzzyIndexes := make([]string, 0, len(sch.Kinds))
// exact match
for k, ks := range sch.Kinds {
fuzzyWords = append(fuzzyWords, ks.Description)
fuzzyIndexes = append(fuzzyIndexes, k)
if strings.EqualFold(ks.Description, value) {
return ks.Kind, nil
}
}
// fuzzy match
result := fuzzy.RankFindNormalizedFold(value, fuzzyWords)
bestDesc := "<none>"
bestDist := "-"
if len(result) > 0 {
if bd := result[0].Distance; bd < 26 {
return sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Kind, nil
} else {
bestDesc = sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Description
bestDist = strconv.Itoa(bd)
}
}
return 0, fmt.Errorf("unknown kind: %q (closest: %q, distance: %s)", value, bestDesc, bestDist)
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
@@ -708,17 +635,3 @@ var colors = struct {
color.New(color.Bold, color.FgHiGreen).Sprint,
color.New(color.Bold, color.FgHiGreen).Sprintf,
}
func combineFlags(flagSlices [][]cli.Flag, extraFlags ...cli.Flag) []cli.Flag {
total := 0
for _, s := range flagSlices {
total += len(s)
}
total += len(extraFlags)
result := make([]cli.Flag, 0, total)
for _, s := range flagSlices {
result = append(result, s...)
}
result = append(result, extraFlags...)
return result
}
+46 -47
View File
@@ -2,35 +2,45 @@ package main
import (
"context"
"crypto/sha256"
"fmt"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip46"
"fiatjaf.com/nostr/nip49"
"github.com/chzyer/readline"
"github.com/denisbrodbeck/machineid"
"github.com/fatih/color"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
)
// the default secret key we will use when "--sec" is not provided
func defaultKey() nostr.SecretKey {
mid, err := machineid.ID()
if err != nil {
k := nostr.SecretKey{}
k[29] = 'n'
k[30] = 'a'
k[31] = 'k'
return k
}
var defaultKey = nostr.KeyOne.Hex()
return sha256.Sum256([]byte(mid))
var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
DefaultText: "the key '01'",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: defaultKey,
HideDefault: true,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to use when communicating with nip46 bunkers",
DefaultText: "a random key",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
},
}
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, nostr.SecretKey, error) {
@@ -40,10 +50,10 @@ func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer,
}
var kr nostr.Keyer
if bunker == nil {
kr = keyer.NewPlainKeySigner(key)
} else {
if bunker != nil {
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
} else {
kr = keyer.NewPlainKeySigner(key)
}
return kr, key, nil
@@ -51,15 +61,25 @@ func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer,
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (nostr.SecretKey, *nip46.BunkerClient, error) {
sec := c.String("sec")
if strings.HasPrefix(sec, "bunker://") {
// it's a bunker
bunkerURL := sec
clientKeyHex := c.String("connect-as")
var clientKey nostr.SecretKey
if clientKeyHex != "" {
var err error
clientKey, err = nostr.SecretKeyFromHex(clientKeyHex)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("bunker client key '%s' is invalid: %w", clientKeyHex, err)
}
} else {
clientKey = nostr.Generate()
}
clientKey := getSecretKey(c, "connect-as")
logverbose("[nip46]: connecting to %s with client key %s\n", bunkerURL, clientKey.Hex())
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, sys.Pool, func(s string) {
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, nil, func(s string) {
log(color.CyanString("[nip46]: open the following URL: %s"), s)
})
if err != nil {
@@ -85,37 +105,16 @@ func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (
return sk, nil, nil
}
sk, err := parseSecretKey(sec)
return sk, nil, err
}
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
defer func() {
if err != nil {
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
log("%s auth failed: %s\n", colors.errorf(cleanUrl), err)
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth required, but --auth flag not given")
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
return ski.(nostr.SecretKey), nil, nil
}
var kr nostr.Keyer
if nip46.IsBunkerClientOperation(ctx) {
kr = keyer.NewPlainKeySigner(getSecretKey(c, "connect-as"))
} else {
kr, _, err = gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("invalid secret key: %w", err)
}
pk, _ := kr.GetPublicKey(ctx)
log("authenticating as %s... ", color.YellowString("%s…", pk.Hex()[0:16]))
return kr.SignEvent(ctx, authEvent)
return sk, nil, nil
}
func promptDecrypt(ncryptsec string) (nostr.SecretKey, error) {
+7 -25
View File
@@ -14,16 +14,11 @@ def value(tagName): tag(tagName)[1];
def has(tagName): (tags(tagName) | length) > 0;
def hasnt(tagName): (tags(tagName) | length) == 0;
def has_value(tagName; tagValue): tags(tagName) | map(select(.[1] == tagValue)) | length > 0;
def date: .created_at | gmtime | strftime("%Y-%m-%d");
def time: .created_at | gmtime | strftime("%H:%M:%S");
def datetime: .created_at | gmtime | strftime("%Y-%m-%dT%H:%M:%SZ");
`
// jqProcessor runs the compiled jq expression against an event and returns the
// rendered output string. When matches is false the event should be skipped.
type jqProcessor func(nostr.Event) (out string, matches bool, err error)
type jqProcessor func(nostr.Event) (any, bool, error)
func jqPrepare(expr string, raw bool) (jqProcessor, error) {
func jqPrepare(expr string) (jqProcessor, error) {
if expr == "" {
return nil, nil
}
@@ -38,43 +33,30 @@ func jqPrepare(expr string, raw bool) (jqProcessor, error) {
return nil, fmt.Errorf("failed to compile jq expression: %w", err)
}
return func(evt nostr.Event) (string, bool, error) {
return func(evt nostr.Event) (any, bool, error) {
input, err := toJQInput(evt)
if err != nil {
return "", false, err
return nil, false, err
}
iter := code.Run(input)
for {
v, ok := iter.Next()
if !ok {
return "", false, nil
return v, false, nil
}
if err, ok := v.(error); ok {
return "", false, err
return v, false, err
}
if jqTruthy(v) {
return jqStringify(v, raw), true, nil
return v, true, nil
}
}
}, nil
}
// jqStringify renders a jq result value. In raw mode string results are printed
// without JSON quoting (like `jq -r`); non-string results are always
// JSON-encoded.
func jqStringify(v any, raw bool) string {
if raw {
if s, ok := v.(string); ok {
return s
}
}
out, _ := json.MarshalToString(v)
return out
}
func toJQInput(v any) (any, error) {
data, err := json.Marshal(v)
if err != nil {
+3 -37
View File
@@ -28,7 +28,6 @@ var key = &cli.Command{
decryptKey,
combine,
validate,
defaultCommand,
},
}
@@ -57,21 +56,7 @@ var public = &cli.Command{
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) == 0 && !isPiped() {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get public key: %w", err)
}
stdout(pk.Hex())
return nil
}
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, args) {
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
_, pk := btcec.PrivKeyFromBytes(sk[:])
if c.Bool("with-parity") {
@@ -187,8 +172,7 @@ var decryptKey = &cli.Command{
}
} else {
password = c.Args().Get(0)
// the ncryptsec codes come from stdin
for ncryptsec := range getStdinLinesOrArgumentsFromSlice(nil) {
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
sk, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
@@ -196,7 +180,6 @@ var decryptKey = &cli.Command{
}
stdout(sk.Hex())
}
exitIfLineProcessingError(ctx)
return nil
}
default:
@@ -228,7 +211,7 @@ Returns error if key is invalid, otherwise exits successfully.`,
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
continue
}
tmp := data.(nostr.PubKey)
tmp := data.([32]byte)
pkBytes = tmp[:]
} else {
pkBytes, err = hex.DecodeString(pk)
@@ -252,17 +235,6 @@ Returns error if key is invalid, otherwise exits successfully.`,
},
}
var defaultCommand = &cli.Command{
Name: "default",
Usage: "prints the default secret key",
Description: `the default secret key is generated differently for each machine (or falls back to a hardcoded one in case of failure), it is not save in any way, but this command makes it available if that is needed.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
stdout(nip19.EncodeNsec(defaultKey()))
return nil
},
}
var combine = &cli.Command{
Name: "combine",
Usage: "combines two or more pubkeys using musig2",
@@ -326,12 +298,6 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
continue
}
return fmt.Errorf("invalid key %s: expected 32 or 33 bytes hex", keyhex)
}
if len(keyGroups) < 2 {
return fmt.Errorf("at least two pubkeys are required")
}
result.Combinations = make([]Combination, 0, 16)
-64
View File
@@ -1,64 +0,0 @@
package main
import (
"context"
"fmt"
"strconv"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
var kindCmd = &cli.Command{
Name: "kind",
Usage: "look up information about a Nostr event kind",
Description: `takes a kind number or a kind name and prints information about that kind from the registry of kinds schema.
example:
nak kind 1
nak kind "text note"
nak kind "git meta"
nak kind "fav relays"
nak kind 30023`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("requires exactly one argument: kind number or name")
}
input := c.Args().First()
// resolve input to a kind number
var k nostr.Kind
if n, err := strconv.ParseUint(input, 10, 16); err == nil && n >= 0 {
k = nostr.Kind(n)
} else {
resolved, err := stringToKind(input)
if err != nil {
return fmt.Errorf("failed to resolve kind: %w", err)
}
k = resolved
}
sch, err := getSchema()
if err != nil {
return err
}
ks := sch.Kinds[strconv.Itoa(int(k.Num()))]
j, _ := json.MarshalIndent(ks, "", " ")
stdout(string(j))
return nil
},
}
+2 -3
View File
@@ -22,10 +22,9 @@ func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
hintsPath := filepath.Join(configPath, "outbox/hints")
os.MkdirAll(hintsPath, 0755)
if hdb, err := lmdbh.NewLMDBHints(hintsPath); err != nil {
_, err := lmdbh.NewLMDBHints(hintsPath)
if err != nil {
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
} else {
sys.Hints = hdb
}
eventsPath := filepath.Join(configPath, "events")
+38 -111
View File
@@ -6,56 +6,13 @@ import (
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var authFlags = []cli.Flag{
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_AUTH,
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, wait for a nip42 \"AUTH\" message to be received, act on it and only then send the query",
Category: CATEGORY_AUTH,
},
}
var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: defaultKey().Hex(),
DefaultText: "a default key specific to your machine, see it with `nak key default`",
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Category: CATEGORY_SIGNER,
Hidden: true,
},
&SecretKeyFlag{
Name: "connect-as",
Usage: "private key to use when communicating with nip46 bunkers",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
Value: defaultKey(),
DefaultText: "the default key (see `nak key default`)",
Hidden: true,
},
}
var (
version string = "debug"
isVerbose bool = false
@@ -76,7 +33,6 @@ var app = &cli.Command{
decode,
encode,
key,
kindCmd,
verify,
relay,
admin,
@@ -96,7 +52,6 @@ var app = &cli.Command{
nsite,
git,
group,
podcast,
nip,
syncCmd,
spell,
@@ -104,63 +59,47 @@ var app = &cli.Command{
validateCmd,
},
Version: version,
Flags: combineFlags([][]cli.Flag{
{
&cli.StringFlag{
Name: "config-path",
Hidden: true,
Value: (func() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config/nak")
} else {
return ""
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config-path",
Hidden: true,
Value: (func() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config/nak")
} else {
return ""
}
})(),
},
&cli.BoolFlag{
Name: "quiet",
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
Aliases: []string{"q"},
Action: func(ctx context.Context, c *cli.Command, b bool) error {
q := c.Count("quiet")
if q >= 1 {
log = func(msg string, args ...any) {}
if q >= 2 {
stdout = func(_ ...any) {}
}
})(),
},
&cli.BoolFlag{
Name: "quiet",
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
Aliases: []string{"q"},
Action: func(ctx context.Context, c *cli.Command, b bool) error {
q := c.Count("quiet")
if q >= 1 {
log = func(msg string, args ...any) {}
if q >= 2 {
stdout = func(_ ...any) {}
}
}
return nil
},
Hidden: true,
},
&cli.BoolFlag{
Name: "verbose",
Usage: "print more stuff than normally",
Aliases: []string{"v"},
Action: func(ctx context.Context, c *cli.Command, b bool) error {
v := c.Count("verbose")
if v >= 1 {
logverbose = log
isVerbose = true
}
return nil
},
Hidden: true,
},
&cli.DurationFlag{
Name: "connect-timeout",
Usage: "timeout for connecting to relays",
Value: connectTimeout,
Action: func(ctx context.Context, c *cli.Command, d time.Duration) error {
connectTimeout = d
return nil
},
Hidden: true,
}
return nil
},
},
defaultKeyFlags,
authFlags,
}),
&cli.BoolFlag{
Name: "verbose",
Usage: "print more stuff than normally",
Aliases: []string{"v"},
Action: func(ctx context.Context, c *cli.Command, b bool) error {
v := c.Count("verbose")
if v >= 1 {
logverbose = log
isVerbose = true
}
return nil
},
},
},
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
sys = sdk.NewSystem()
@@ -171,18 +110,6 @@ var app = &cli.Command{
sys.Pool.RelayOptions = nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
return ctx, nil
},
+4 -5
View File
@@ -19,7 +19,9 @@ var mcpServer = &cli.Command{
Usage: "pander to the AI gods",
Description: ``,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{},
Flags: append(
defaultKeyFlags,
),
Action: func(ctx context.Context, c *cli.Command) error {
s := server.NewMCPServer(
"nak",
@@ -155,9 +157,6 @@ var mcpServer = &cli.Command{
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name := required[string](r, "name")
limit, _ := optional[float64](r, "limit")
if limit == 0 {
limit = 10
}
res := strings.Builder{}
res.Grow(500)
@@ -242,7 +241,7 @@ var mcpServer = &cli.Command{
for ie := range events {
result.WriteString("author public key: ")
result.WriteString(ie.PubKey.Hex())
result.WriteString("\ncontent: '")
result.WriteString("content: '")
result.WriteString(ie.Content)
result.WriteString("'")
result.WriteString("\n---\n")
+3 -15
View File
@@ -5,14 +5,12 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/charmbracelet/glamour"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
type nipInfo struct {
@@ -65,6 +63,7 @@ examples:
}
url := "https://github.com/nostr-protocol/nips/blob/master/" + foundLink
fmt.Println("Opening " + url)
var cmd *exec.Cmd
switch runtime.GOOS {
@@ -130,23 +129,12 @@ examples:
}
// render markdown
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width < 80 {
width = 120
}
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(width),
)
if err != nil {
return fmt.Errorf("failed to create renderer: %w", err)
}
rendered, err := r.Render(string(body))
rendered, err := glamour.Render(string(body), "auto")
if err != nil {
return fmt.Errorf("failed to render markdown: %w", err)
}
stdout(rendered)
fmt.Print(rendered)
return nil
},
}
+1 -1
View File
@@ -5,9 +5,9 @@ import (
"sync/atomic"
"syscall"
"fiatjaf.com/nostr"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"fiatjaf.com/nostr"
)
type AsyncFile struct {
+1 -4
View File
@@ -996,13 +996,10 @@ func (r *NostrRoot) publishNote(path string) {
}
for _, url := range relays {
connectCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
relay, err := nostr.RelayConnect(connectCtx, url, r.sys.Pool.RelayOptions)
cancel()
relay, err := r.sys.Pool.EnsureRelay(url)
if err != nil {
continue
}
r.sys.Pool.Relays.Store(nostr.NormalizeURL(url), relay)
relay.Publish(ctx, *evt)
}
+8 -37
View File
@@ -23,7 +23,7 @@ var nsite = &cli.Command{
Usage: "publishes and downloads nip-5A static sites",
ArgsUsage: "<directory> [relay...]",
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{}),
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "upload",
@@ -31,10 +31,6 @@ var nsite = &cli.Command{
ArgsUsage: "<directory> [relay...]",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "outbox",
Usage: "append given relays to user's outbox relays instead of using them exclusively",
},
&cli.BoolFlag{
Name: "root",
Usage: "publish root site as kind 15128",
@@ -44,10 +40,6 @@ var nsite = &cli.Command{
Aliases: []string{"d"},
Usage: "publish named site as kind 35128 with this d tag",
},
&cli.StringFlag{
Name: "title",
Usage: "a human-readable title for the site",
},
&cli.StringFlag{
Name: "description",
Usage: "a human-readable description of the site",
@@ -62,11 +54,6 @@ var nsite = &cli.Command{
Usage: "blossom server hostname or URL, can be given multiple times",
DefaultText: "defaults to the publisher's list of preferred blossom servers",
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "inject extra tag into the manifest event, e.g. -t action=profile",
},
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
@@ -107,7 +94,6 @@ var nsite = &cli.Command{
Root: root,
Identifier: identifier,
Paths: make(map[string][32]byte),
Title: c.String("title"),
Description: c.String("description"),
Source: c.String("source"),
}
@@ -193,43 +179,28 @@ var nsite = &cli.Command{
}
evt := manifest.ToEvent()
for _, tagFlag := range c.StringSlice("tag") {
tagName, tagValue, found := strings.Cut(tagFlag, "=")
tag := []string{tagName}
if found {
tag = append(tag, strings.Split(tagValue, ";")...)
}
evt.Tags = append(evt.Tags, tag)
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("error signing manifest event: %w", err)
}
givenRelays := c.Args().Slice()[1:]
var relayURLs []string
if len(givenRelays) == 0 {
relayURLs = sys.FetchWriteRelays(ctx, pk)
} else if c.Bool("outbox") {
relayURLs = nostr.AppendUnique(sys.FetchWriteRelays(ctx, pk), givenRelays...)
} else {
relayURLs = givenRelays
}
relayURLs := nostr.AppendUnique(sys.FetchWriteRelays(ctx, pk), c.Args().Slice()[1:]...)
if len(relayURLs) == 0 {
return fmt.Errorf("no relays to publish this nsite to")
}
relays := connectToAllRelays(ctx, c, relayURLs)
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(string, ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayURLs, nil)
if len(relays) == 0 {
return fmt.Errorf("failed to connect to any of [ %v ]", relayURLs)
}
stdout(evt.String())
if identifier == "" {
log(nip19.EncodeNpub(pk) + "\n")
stdout(nip19.EncodeNpub(pk))
} else {
log(nip5a.PubKeyToBase36(pk) + identifier + "\n")
stdout(nip5a.PubKeyToBase36(pk) + identifier)
}
return publishFlow(ctx, c, kr, evt, relays)
-625
View File
@@ -1,625 +0,0 @@
package main
import (
"context"
"fmt"
"os/exec"
"runtime"
"slices"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
type podcastInfo struct {
PubKey nostr.PubKey
Title string
Image string
Relays []string
Metadata nostr.Event
}
var podcast = &cli.Command{
Name: "podcast",
Usage: "play podcasts from Nostr",
Commands: []*cli.Command{
{
Name: "play",
Usage: "play latest episode from podcast or specific episode",
ArgsUsage: "<pubkey|nip05|npub|nprofile|nevent>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.StringFlag{
Name: "player",
Usage: "player command to use, defaults to auto-detect",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast or episode target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
episode, err := resolvePodcastEpisode(ctx, target, relays)
if err != nil {
return err
}
if err := playPodcastEpisode(c.String("player"), episode); err != nil {
return err
}
}
return nil
},
},
{
Name: "info",
Usage: "display podcast metadata",
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
if err != nil {
return err
}
for _, p := range podcasts {
printPodcastInfo(p)
}
}
return nil
},
},
{
Name: "list",
Usage: "list podcast episodes",
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "maximum number of episodes to fetch",
Value: 50,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
if err != nil {
return err
}
for _, p := range podcasts {
if err := printPodcastEpisodeList(ctx, p, int(c.Int("limit"))); err != nil {
return err
}
}
}
return nil
},
},
},
}
func resolvePodcastEpisode(ctx context.Context, target string, relayHints []string) (*nostr.Event, error) {
target = strings.TrimSpace(strings.TrimPrefix(target, "nostr:"))
if prefix, value, err := nip19.Decode(target); err == nil {
switch prefix {
case "nevent":
pointer := value.(nostr.EventPointer)
return fetchSpecificPodcastEpisode(ctx, pointer, relayHints)
case "note":
return fetchSpecificPodcastEpisode(ctx, value.(nostr.EventPointer), relayHints)
}
}
if pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target); err == nil {
return fetchLatestEpisodeForPodcastSelection(ctx, pk, nostr.AppendUnique(relayHints, extraRelays...))
}
return nil, fmt.Errorf("invalid podcast target %q", target)
}
func resolvePodcastPubKeyTarget(ctx context.Context, target string) (nostr.PubKey, []string, error) {
if strings.TrimSpace(target) == "" {
return nostr.ZeroPK, nil, fmt.Errorf("missing pubkey target")
}
if strings.HasPrefix(target, "nostr:") {
target = strings.TrimPrefix(target, "nostr:")
}
if prefix, value, err := nip19.Decode(target); err == nil {
switch prefix {
case "npub":
return value.(nostr.PubKey), nil, nil
case "nprofile":
profile := value.(nostr.ProfilePointer)
return profile.PublicKey, profile.Relays, nil
}
}
if strings.Contains(target, "@") {
pp, err := nip05.QueryIdentifier(ctx, target)
if err != nil {
return nostr.ZeroPK, nil, err
}
return pp.PublicKey, pp.Relays, nil
}
pk, err := nostr.PubKeyFromHex(target)
if err != nil {
return nostr.ZeroPK, nil, err
}
return pk, nil, nil
}
func fetchSpecificPodcastEpisode(ctx context.Context, pointer nostr.EventPointer, relayHints []string) (*nostr.Event, error) {
relays := slices.Clone(relayHints)
relays = nostr.AppendUnique(relays, pointer.Relays...)
if pointer.Author != nostr.ZeroPK {
relays = nostr.AppendUnique(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for episode")
}
res := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}}, nostr.SubscriptionOptions{Label: "nak-podcast"})
if res == nil {
return nil, fmt.Errorf("podcast episode not found")
}
if res.Event.Kind != 54 {
return nil, fmt.Errorf("event is not podcast episode: expected kind 54, got %d", res.Event.Kind)
}
return &res.Event, nil
}
func fetchLatestEpisodeForPodcastSelection(ctx context.Context, pk nostr.PubKey, relayHints []string) (*nostr.Event, error) {
choices, err := discoverPodcasts(ctx, pk, relayHints)
if err != nil {
return nil, err
}
chosen := choices[0]
if len(choices) > 1 {
labels := make([]string, len(choices))
for i, item := range choices {
labels[i] = podcastLabel(item)
}
var selected string
if err := survey.AskOne(&survey.Select{
Message: "choose podcast",
Options: labels,
PageSize: 12,
}, &selected); err != nil {
return nil, err
}
for i, label := range labels {
if label == selected {
chosen = choices[i]
break
}
}
}
episode, err := fetchLatestPodcastEpisode(ctx, chosen)
if err != nil {
return nil, err
}
return episode, nil
}
func discoverPodcasts(ctx context.Context, pk nostr.PubKey, relayHints []string) ([]podcastInfo, error) {
choices := make([]podcastInfo, 0, 4)
seen := make(map[nostr.PubKey]struct{}, 4)
if direct, ok := fetchPodcastMetadata(ctx, pk, relayHints); ok {
choices = append(choices, direct)
seen[pk] = struct{}{}
}
authoredRelays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
for _, candidatePubKey := range fetchAuthoredPodcastPubKeys(ctx, pk, authoredRelays) {
if _, ok := seen[candidatePubKey]; ok {
continue
}
meta, ok := fetchPodcastMetadata(ctx, candidatePubKey, relayHints)
if !ok {
continue
}
if !podcastClaimsAuthor(meta.Metadata, pk) {
continue
}
choices = append(choices, meta)
seen[candidatePubKey] = struct{}{}
}
if len(choices) == 0 {
return nil, fmt.Errorf("no podcasts found for %s", nip19.EncodeNpub(pk))
}
return choices, nil
}
func fetchPodcastMetadata(ctx context.Context, pk nostr.PubKey, relayHints []string) (podcastInfo, bool) {
relays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
if len(relays) == 0 {
return podcastInfo{}, false
}
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10154},
Authors: []nostr.PubKey{pk},
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
if !ok {
return podcastInfo{}, false
}
return podcastInfo{
PubKey: pk,
Title: podcastTagValue(evt, "title"),
Image: podcastTagValue(evt, "image"),
Relays: relays,
Metadata: evt,
}, true
}
func fetchAuthoredPodcastPubKeys(ctx context.Context, pk nostr.PubKey, relays []string) []nostr.PubKey {
if len(relays) == 0 {
return nil
}
found := make([]nostr.PubKey, 0, 4)
seen := make(map[nostr.PubKey]struct{}, 4)
for _, kind := range []nostr.Kind{10064, 10164} {
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{kind},
Authors: []nostr.PubKey{pk},
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
if !ok {
continue
}
for _, tag := range evt.Tags {
if len(tag) < 2 || tag[0] != "p" {
continue
}
podcastPK, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
if _, ok := seen[podcastPK]; ok {
continue
}
seen[podcastPK] = struct{}{}
found = append(found, podcastPK)
}
}
return found
}
func podcastClaimsAuthor(metadata nostr.Event, author nostr.PubKey) bool {
for _, tag := range metadata.Tags {
if len(tag) >= 2 && tag[0] == "p" && tag[1] == author.Hex() {
return true
}
}
return false
}
func fetchLatestPodcastEpisode(ctx context.Context, podcast podcastInfo) (*nostr.Event, error) {
relays := podcast.Relays
if len(relays) == 0 {
relays = sys.FetchOutboxRelays(ctx, podcast.PubKey, 3)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(podcast.PubKey))
}
var latest *nostr.Event
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{54},
Authors: []nostr.PubKey{podcast.PubKey},
Limit: 20,
}, nostr.SubscriptionOptions{Label: "nak-podcast"}) {
if latest == nil || ie.Event.CreatedAt > latest.CreatedAt {
event := ie.Event
latest = &event
}
}
if latest == nil {
return nil, fmt.Errorf("no episodes found for %s", podcastLabel(podcast))
}
return latest, nil
}
func playPodcastEpisode(player string, episode *nostr.Event) error {
audioURL := firstPodcastAudioURL(*episode)
if audioURL == "" {
return fmt.Errorf("podcast episode has no audio tag")
}
title := podcastTagValue(*episode, "title")
if title == "" {
title = episode.ID.Hex()
}
stdout(colors.bold("playing:"), color.HiBlueString(title))
stdout(colors.bold("audio:"), color.HiCyanString(audioURL))
cmd, err := buildPodcastPlayerCommand(player, audioURL)
if err != nil {
return err
}
cmd.Stdin = nil
cmd.Stdout = color.Output
cmd.Stderr = color.Error
return cmd.Run()
}
func buildPodcastPlayerCommand(player string, audioURL string) (*exec.Cmd, error) {
if strings.TrimSpace(player) != "" {
parts := strings.Fields(player)
if len(parts) == 0 {
return nil, fmt.Errorf("invalid player command %q", player)
}
return exec.Command(parts[0], append(parts[1:], audioURL)...), nil
}
for _, candidate := range podcastPlayerCandidates() {
if _, err := exec.LookPath(candidate.name); err == nil {
return exec.Command(candidate.name, append(candidate.args, audioURL)...), nil
}
}
return nil, fmt.Errorf("no media player found; install mpv, vlc, ffplay or pass --player")
}
func podcastPlayerCandidates() []struct {
name string
args []string
} {
if runtime.GOOS == "darwin" {
return []struct {
name string
args []string
}{
{"mpv", nil},
{"vlc", nil},
{"ffplay", []string{"-nodisp", "-autoexit"}},
{"open", nil},
}
}
if runtime.GOOS == "windows" {
return []struct {
name string
args []string
}{
{"mpv.exe", nil},
{"vlc.exe", nil},
{"ffplay.exe", []string{"-nodisp", "-autoexit"}},
{"cmd", []string{"/c", "start"}},
}
}
return []struct {
name string
args []string
}{
{"mpv", nil},
{"vlc", nil},
{"ffplay", []string{"-nodisp", "-autoexit"}},
{"xdg-open", nil},
}
}
func firstPodcastAudioURL(evt nostr.Event) string {
for _, tag := range evt.Tags {
if len(tag) >= 2 && tag[0] == "audio" {
return tag[1]
}
}
return ""
}
func podcastTagValue(evt nostr.Event, name string) string {
if tag := evt.Tags.Find(name); len(tag) >= 2 {
return tag[1]
}
return ""
}
func podcastLabel(info podcastInfo) string {
title := strings.TrimSpace(info.Title)
if title == "" {
title = nip19.EncodeNpub(info.PubKey)
} else {
title += " (" + nip19.EncodeNpub(info.PubKey) + ")"
}
return title
}
func resolvePodcastsForPubkey(ctx context.Context, target string, relayHints []string) ([]podcastInfo, error) {
pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target)
if err != nil {
return nil, err
}
allRelays := nostr.AppendUnique(relayHints, extraRelays...)
choices, err := discoverPodcasts(ctx, pk, allRelays)
if err != nil {
return nil, err
}
if len(choices) > 1 {
labels := make([]string, len(choices))
for i, item := range choices {
labels[i] = podcastLabel(item)
}
var selected string
if err := survey.AskOne(&survey.Select{
Message: "choose podcast",
Options: labels,
PageSize: 12,
}, &selected); err != nil {
return nil, err
}
filtered := make([]podcastInfo, 0, 1)
for i, label := range labels {
if label == selected {
filtered = append(filtered, choices[i])
break
}
}
return filtered, nil
}
return choices, nil
}
func printPodcastInfo(p podcastInfo) {
stdout(colors.bold("title:"), color.HiBlueString(p.Title))
stdout(colors.bold("pubkey:"), color.HiCyanString(nip19.EncodeNpub(p.PubKey)))
if desc := podcastTagValue(p.Metadata, "description"); desc != "" {
stdout(colors.bold("description:"), desc)
}
if image := podcastTagValue(p.Metadata, "image"); image != "" {
stdout(colors.bold("image:"), color.HiCyanString(image))
}
if website := podcastTagValue(p.Metadata, "website"); website != "" {
stdout(colors.bold("website:"), color.HiCyanString(website))
}
authors := make([]string, 0, 2)
for _, tag := range p.Metadata.Tags {
if len(tag) >= 2 && tag[0] == "p" {
pk, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
author := nip19.EncodeNpub(pk)
if len(tag) >= 3 && tag[2] != "" {
author += " " + tag[2]
}
authors = append(authors, author)
}
}
if len(authors) > 0 {
stdout(colors.bold("authors:"), strings.Join(authors, ", "))
}
stdout("")
}
func printPodcastEpisodeList(ctx context.Context, p podcastInfo, limit int) error {
episodes, err := fetchPodcastEpisodes(ctx, p, limit)
if err != nil {
return err
}
if len(episodes) == 0 {
stdout(color.YellowString("no episodes found for %s", p.Title))
return nil
}
stdout(color.CyanString("episodes for %s:", p.Title))
for _, ep := range episodes {
title := podcastTagValue(ep, "title")
if title == "" {
title = ep.ID.Hex()[:16]
}
date := ep.CreatedAt.Time().Format("2006-01-02")
desc := ep.Content
if len(desc) > 100 {
desc = desc[:100] + "..."
}
stdout(fmt.Sprintf("%s %s %s", date, title, desc))
}
return nil
}
func fetchPodcastEpisodes(ctx context.Context, p podcastInfo, limit int) ([]nostr.Event, error) {
relays := p.Relays
if len(relays) == 0 {
relays = sys.FetchOutboxRelays(ctx, p.PubKey, 3)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(p.PubKey))
}
episodes := make([]nostr.Event, 0, limit)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{54},
Authors: []nostr.PubKey{p.PubKey},
Limit: limit,
}, nostr.SubscriptionOptions{Label: "nak-podcast-list"}) {
episodes = append(episodes, ie.Event)
}
return episodes, nil
}
-38
View File
@@ -3,8 +3,6 @@ package main
import (
"context"
stdjson "encoding/json"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
@@ -18,24 +16,6 @@ var profile = &cli.Command{
example usage:
nak profile npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps
nak profile user@example.com`,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "name",
Usage: "print only the name",
},
&cli.BoolFlag{
Name: "picture",
Usage: "print only the picture URL",
},
&cli.BoolFlag{
Name: "about",
Usage: "print only the about text",
},
&cli.BoolFlag{
Name: "metadata",
Usage: "print the profile metadata as JSON",
},
},
ArgsUsage: "[pubkey]",
Action: func(ctx context.Context, c *cli.Command) error {
for pubkeyInput := range getStdinLinesOrArguments(c.Args()) {
@@ -47,24 +27,6 @@ example usage:
pm := sys.FetchProfileMetadata(ctx, pk)
if c.Bool("name") {
stdout(pm.Name)
continue
}
if c.Bool("picture") {
stdout(pm.Picture)
continue
}
if c.Bool("about") {
stdout(pm.About)
continue
}
if c.Bool("metadata") {
j, _ := stdjson.Marshal(pm)
stdout(string(j))
continue
}
npub := nip19.EncodeNpub(pk)
stdout(colors.bold("pubkey (hex):"), pk.Hex())
stdout(colors.bold("npub:"), color.HiCyanString(npub))
+57 -182
View File
@@ -8,36 +8,26 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
var publish = &cli.Command{
Name: "publish",
Usage: "publishes a note with content from stdin or argument",
Description: `reads content and publishes it as a note, optionally as a reply to another note.
Either pipe content or pass it as the first argument.
Usage: "publishes a note with content from stdin",
Description: `reads content from stdin and publishes it as a note, optionally as a reply to another note.
example:
echo "hello world" | nak publish
nak publish "hello world"
echo "I agree!" | nak publish --reply nevent1...
nak publish -t t=mytag -t e=someeventid "tagged post"`,
echo "tagged post" | nak publish -t t=mytag -t e=someeventid`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also publish to these relays",
},
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "reply",
Usage: "event id, naddr1 or nevent1 code to reply to",
},
&cli.StringFlag{
Name: "comment",
Usage: "event id, naddr1 or nevent1 code to comment on (NIP-22 kind 1111, takes precedence over --reply)",
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
@@ -50,6 +40,11 @@ example:
DefaultText: "now",
Value: nostr.Now(),
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "nevent",
Usage: "print the nevent code (to stderr) after the event is published",
@@ -62,17 +57,9 @@ example:
},
),
Action: func(ctx context.Context, c *cli.Command) error {
var content []byte
if isPiped() {
var err error
content, err = io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
} else if firstArg := c.Args().First(); firstArg != "" {
content = []byte(firstArg)
} else {
return fmt.Errorf("no content. pipe or pass content as argument.")
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
evt := nostr.Event{
@@ -87,68 +74,52 @@ example:
evt.CreatedAt = getNaturalDate(c, "created-at")
}
// handle reply/comment flag
// --comment takes precedence over --reply
target := c.String("comment")
isComment := c.IsSet("comment")
if target == "" {
target = c.String("reply")
}
// handle reply flag
var replyRelays []string
if target != "" {
replyEvent, relayHints, err := sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{})
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
if replyTo := c.String("reply"); replyTo != "" {
var replyEvent *nostr.Event
if isComment || replyEvent.Kind != 1 {
// NIP-22 comment
evt.Kind = 1111
buildNIP22Tags(&evt, replyEvent, relayHints)
// try to decode as nevent or naddr first
if strings.HasPrefix(replyTo, "nevent1") || strings.HasPrefix(replyTo, "naddr1") {
_, value, err := nip19.Decode(replyTo)
if err != nil {
return fmt.Errorf("invalid reply target: %w", err)
}
switch pointer := value.(type) {
case nostr.EventPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
case nostr.EntityPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
default:
return fmt.Errorf("unexpected reply target type: %T", value)
}
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
} else {
// NIP-10 reply
if replyEvent.Kind != 1 {
evt.Kind = 1111
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
// try as raw event ID
id, err := nostr.IDFromHex(replyTo)
if err != nil {
return fmt.Errorf("invalid event id: %w", err)
}
// add reply tags per NIP-10
rootID := ""
rootRelay := ""
for _, tag := range replyEvent.Tags {
if len(tag) >= 2 && tag[0] == "e" {
if len(tag) >= 4 && tag[3] == "root" {
rootID = tag[1]
if len(tag) >= 3 {
rootRelay = tag[2]
}
break
}
if rootID == "" {
rootID = tag[1]
if len(tag) >= 3 {
rootRelay = tag[2]
}
}
}
replyEvent, _, err = sys.FetchSpecificEvent(ctx, nostr.EventPointer{ID: id}, sdk.FetchSpecificEventParameters{})
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
if rootID == "" {
// replying to root event
evt.Tags = append(evt.Tags,
nostr.Tag{"e", replyEvent.ID.Hex(), "", "root"},
)
} else {
// replying to a reply
evt.Tags = append(evt.Tags,
nostr.Tag{"e", rootID, rootRelay, "root"},
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
)
}
evt.Tags = append(evt.Tags,
nostr.Tag{"p", replyEvent.PubKey.Hex()},
)
}
if replyEvent.Kind != 1 {
evt.Kind = 1111
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
}
// add reply tags
evt.Tags = append(evt.Tags,
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
nostr.Tag{"p", replyEvent.PubKey.Hex()},
)
replyRelays = sys.FetchInboxRelays(ctx, replyEvent.PubKey, 3)
}
@@ -182,9 +153,12 @@ example:
relayUrls := sys.FetchWriteRelays(ctx, pk)
relayUrls = nostr.AppendUnique(relayUrls, targetRelays...)
relayUrls = nostr.AppendUnique(relayUrls, replyRelays...)
relayUrls = nostr.AppendUnique(relayUrls, c.StringSlice("relay")...)
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
relays := connectToAllRelays(ctx, c, relayUrls)
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 {
if len(relayUrls) == 0 {
@@ -206,102 +180,3 @@ example:
return publishFlow(ctx, c, kr, evt, relays)
},
}
func buildNIP22Tags(evt *nostr.Event, target *nostr.Event, relayHints []string) {
var relayHint string
if len(relayHints) > 0 {
relayHint = relayHints[0]
}
dtag := func() string {
for _, tag := range target.Tags {
if len(tag) >= 2 && tag[0] == "d" {
return tag[1]
}
}
return ""
}
// find root scope in target's uppercase NIP-22 tags
var rootScopeName, rootScopeValue, rootScopeRelay, rootKind, rootPubkey string
for _, tag := range target.Tags {
if len(tag) >= 2 && (tag[0] == "E" || tag[0] == "A" || tag[0] == "I") {
rootScopeName = tag[0]
rootScopeValue = tag[1]
if len(tag) >= 3 {
rootScopeRelay = tag[2]
}
break
}
}
if rootScopeName != "" {
for _, tag := range target.Tags {
if len(tag) >= 2 && tag[0] == "K" {
rootKind = tag[1]
break
}
}
for _, tag := range target.Tags {
if len(tag) >= 2 && tag[0] == "P" {
rootPubkey = tag[1]
break
}
}
} else {
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
rootScopeName = "A"
rootScopeValue = fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
} else {
rootScopeName = "E"
rootScopeValue = target.ID.Hex()
}
rootScopeRelay = relayHint
rootKind = fmt.Sprint(target.Kind)
rootPubkey = target.PubKey.Hex()
}
// root scope tag
rootScopeTag := nostr.Tag{rootScopeName, rootScopeValue}
if rootScopeName == "E" && rootPubkey != "" {
// the pubkey goes in the 4th position, so the relay hint must be
// present (even if empty) to keep it there
rootScopeTag = append(rootScopeTag, rootScopeRelay, rootPubkey)
} else if rootScopeRelay != "" {
rootScopeTag = append(rootScopeTag, rootScopeRelay)
}
evt.Tags = append(evt.Tags, rootScopeTag)
if rootKind != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"K", rootKind})
}
if rootPubkey != "" && rootScopeName != "I" {
evt.Tags = append(evt.Tags, nostr.Tag{"P", rootPubkey})
}
// parent tags (lowercase) - always reference the target event
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
aValue := fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
aTag := nostr.Tag{"a", aValue}
if relayHint != "" {
aTag = append(aTag, relayHint)
}
evt.Tags = append(evt.Tags, aTag)
}
eTag := nostr.Tag{"e", target.ID.Hex()}
if target.PubKey != (nostr.PubKey{}) {
// same here: keep the pubkey in the 4th position
eTag = append(eTag, relayHint, target.PubKey.Hex())
} else if relayHint != "" {
eTag = append(eTag, relayHint)
}
evt.Tags = append(evt.Tags, eTag)
evt.Tags = append(evt.Tags, nostr.Tag{"k", fmt.Sprint(target.Kind)})
pTag := nostr.Tag{"p", target.PubKey.Hex()}
if relayHint != "" {
pTag = append(pTag, relayHint)
}
evt.Tags = append(evt.Tags, pTag)
}
-1
View File
@@ -31,7 +31,6 @@ var relay = &cli.Command{
pretty, _ := json.MarshalIndent(info, "", " ")
stdout(string(pretty))
}
exitIfLineProcessingError(ctx)
return nil
},
}
+127 -92
View File
@@ -15,8 +15,9 @@ import (
"fiatjaf.com/nostr/eventstore"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/eventstore/wrappers"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip77"
"fiatjaf.com/nostr/schema"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
"golang.org/x/sync/errgroup"
@@ -41,61 +42,69 @@ it can also take a filter from stdin, optionally modify it with flags and send i
example:
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
},
&cli.BoolFlag{
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "skip event signature verification from relays",
},
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "outbox",
Usage: "read from \"write\" relays of \"authors\" and/or from the \"read\" relays of any \"#p\" or \"#P\" tags",
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
DefaultText: "false",
},
&cli.DurationFlag{
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
},
&cli.BoolFlag{
Name: "spell",
Usage: "output a spell event (kind 777) instead of a filter",
},
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "skip event signature verification from relays",
},
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "outbox",
Usage: "read from \"write\" relays of \"authors\" and/or from the \"read\" relays of any \"#p\" or \"#P\" tags",
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
DefaultText: "false",
},
&cli.DurationFlag{
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "spell",
Usage: "output a spell event (kind 777) instead of a filter",
},
)...,
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
@@ -128,7 +137,7 @@ example:
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
}
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
jq, err := jqPrepare(c.String("jq"))
if err != nil {
return err
}
@@ -137,10 +146,30 @@ example:
}
if len(relayUrls) > 0 && !negentropy {
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
)
// stop here already if all connections failed
@@ -333,15 +362,21 @@ func performReq(
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
mu.Lock()
if slices.IndexFunc(defs, matchUrl) == -1 {
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Lock()
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue
}
mu.Unlock()
}
mu.Unlock()
}
return nil
@@ -367,20 +402,30 @@ func performReq(
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
mu.Lock()
if idx := slices.IndexFunc(defs, matchUrl); idx == -1 {
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
} else {
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
mu.Lock()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue // done with this relay url
}
// otherwise we'll just use the idx
mu.Unlock()
}
mu.Unlock()
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
return nil
@@ -425,7 +470,7 @@ readevents:
if !matches {
continue
}
out = v
out, _ = json.MarshalToString(v)
}
stdout(out)
@@ -458,10 +503,10 @@ var reqFilterFlags = []cli.Flag{
Usage: "only accept events with these ids",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&KindSliceFlag{
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers or kind names",
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
@@ -513,16 +558,6 @@ var reqFilterFlags = []cli.Flag{
Usage: "a nip50 search query, use it only with relays that explicitly support it",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
}
type flagTag struct {
@@ -552,8 +587,8 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
if ids := getIDSlice(c, "id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
if kinds := getKindSlice(c, "kind"); len(kinds) > 0 {
filter.Kinds = append(filter.Kinds, kinds...)
for _, kind64 := range c.IntSlice("kind") {
filter.Kinds = append(filter.Kinds, nostr.Kind(kind64))
}
if search := c.String("search"); search != "" {
filter.Search = search
@@ -629,7 +664,7 @@ func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event)
if !matches {
return nil
}
out = v
out, _ = json.MarshalToString(v)
}
stdout(out)
return nil
+1 -40
View File
@@ -64,14 +64,6 @@ var serve = &cli.Command{
Name: "blossom",
Usage: "enable blossom server",
},
&cli.BoolFlag{
Name: "auth",
Usage: "require AUTH for all operations",
},
&cli.BoolFlag{
Name: "eager-auth",
Usage: "send AUTH challenge immediately on connect",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
db := &slicestore.SliceStore{}
@@ -124,19 +116,12 @@ var serve = &cli.Command{
totalConnections := atomic.Int32{}
rl.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
if c.Bool("eager-auth") {
khatru.RequestAuth(ctx)
}
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
rl.OnAuth = func(ctx context.Context, pubkey nostr.PubKey) {
log(" got %s %s\n", color.GreenString("authenticated"), pubkey.Hex())
}
d := debounce.New(time.Second * 2)
var printStatus func()
printStatus = func() {
@@ -243,47 +228,23 @@ var serve = &cli.Command{
// relay logging
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if c.Bool("auth") {
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
return true, "auth-required: subscribe"
}
}
negentropy := ""
if khatru.IsNegentropySession(ctx) {
negentropy = color.HiBlueString("negentropy ")
}
authedString := ""
if pubkey, ok := khatru.GetAuthed(ctx); ok {
authedString = fmt.Sprintf(" from %s", color.GreenString(pubkey.Hex()))
}
log(" got %s%s %v%s\n",
negentropy, color.HiYellowString("request"), colors.italic(filter), authedString)
log(" got %s%s %v\n", negentropy, color.HiYellowString("request"), colors.italic(filter))
printStatus()
return false, ""
}
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if c.Bool("auth") {
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
return true, "auth-required: count"
}
}
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
printStatus()
return false, ""
}
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
if c.Bool("auth") {
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
return true, "auth-required: event"
}
}
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
printStatus()
return false, ""
+1 -1
View File
@@ -23,7 +23,7 @@ var spell = &cli.Command{
Usage: "downloads a spell event and executes its REQ request",
ArgsUsage: "[nevent_code]",
Description: `fetches a spell event (kind 777) and executes REQ command encoded in its tags.`,
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "pub",
Usage: "public key to run spells in the context of (if you don't want to pass a --sec)",
+7 -33
View File
@@ -124,27 +124,9 @@ func getBoundKey(b negentropy.Bound) boundKey {
type RelayThirdPartyRemote struct {
relay *nostr.Relay
messages chan string
mu sync.Mutex
err error
}
// fail records the error and closes the messages channel so a Receive()
// that is already blocked on it wakes up instead of hanging forever.
func (rtpr *RelayThirdPartyRemote) fail(err error) {
rtpr.mu.Lock()
if rtpr.err == nil {
rtpr.err = err
close(rtpr.messages)
}
rtpr.mu.Unlock()
}
func (rtpr *RelayThirdPartyRemote) getErr() error {
rtpr.mu.Lock()
defer rtpr.mu.Unlock()
return rtpr.err
}
func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdPartyRemote, error) {
rtpr := &RelayThirdPartyRemote{
messages: make(chan string, 3),
@@ -159,18 +141,13 @@ func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdParty
}
switch env := envelope.(type) {
case *nip77.OpenEnvelope, *nip77.CloseEnvelope:
rtpr.fail(fmt.Errorf("unexpected %s received from relay", env.Label()))
rtpr.err = fmt.Errorf("unexpected %s received from relay", env.Label())
return
case *nip77.ErrorEnvelope:
rtpr.fail(fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason))
rtpr.err = fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason)
return
case *nip77.MessageEnvelope:
rtpr.mu.Lock()
failed := rtpr.err != nil
rtpr.mu.Unlock()
if !failed {
rtpr.messages <- env.Message
}
rtpr.messages <- env.Message
}
},
})
@@ -208,13 +185,12 @@ func (rtpr *RelayThirdPartyRemote) SendClose() error {
var thirdPartyRemoteEndOfMessages = errors.New("the-end")
func (rtpr *RelayThirdPartyRemote) Receive() (string, error) {
if rtpr.err != nil {
return "", rtpr.err
}
if msg, ok := <-rtpr.messages; ok {
return msg, nil
}
// channel closed by fail()
if err := rtpr.getErr(); err != nil {
return "", err
}
return "", thirdPartyRemoteEndOfMessages
}
@@ -228,9 +204,6 @@ func NewThirdPartyNegentropy(peerA, peerB *RelayThirdPartyRemote, filter nostr.F
}
func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
// always close Deltas so the consumer goroutine doesn't hang when we return an error
defer close(n.Deltas)
peerAIds := make(map[nostr.ID]struct{})
peerBIds := make(map[nostr.ID]struct{})
peerASkippedBounds := make(map[boundKey]struct{})
@@ -368,6 +341,7 @@ func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
n.PeerA.SendClose()
n.PeerB.SendClose()
close(n.Deltas)
return nil
}
+21 -12
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/schema"
@@ -21,20 +22,28 @@ nak event -k 1 -p not_a_pubkey | nak validate
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml",
TakesFile: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
sch, err := getSchema()
if err != nil {
return err
}
var validator schema.Validator
validator := schema.NewValidatorFromSchema(sch)
if schemaURL := c.String("schema"); strings.HasPrefix(schemaURL, "http") {
var err error
validator, err = schema.NewValidatorFromURL(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from '%s': %w", schemaURL, err)
}
} else {
var err error
validator, err = schema.NewValidatorFromFile(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from %s: %w", schemaURL, err)
}
}
handleEvent := func(stdinEvent string) error {
evt := nostr.Event{}
@@ -47,12 +56,12 @@ nak event -k 1 -p not_a_pubkey | nak validate
}
stdout(evt)
return nil
}
for stdinEvent := range getJsonsOrBlank() {
if stdinEvent == "{}" && !isPiped() {
// blank sentinel from getJsonsOrBlank(), use the arguments instead
if stdinEvent == "" {
for _, arg := range c.Args().Slice() {
if err := handleEvent(arg); err != nil {
ctx = lineProcessingError(ctx, "%s", err)
+1 -2
View File
@@ -19,8 +19,7 @@ it outputs nothing if the verification is successful.`,
Action: func(ctx context.Context, c *cli.Command) error {
for stdinEvent := range getJsonsOrBlank() {
evt := nostr.Event{}
if stdinEvent == "{}" && !isPiped() {
// blank sentinel from getJsonsOrBlank(), use the argument instead
if stdinEvent == "" {
stdinEvent = c.Args().First()
if stdinEvent == "" {
continue
+4 -14
View File
@@ -105,7 +105,7 @@ var wallet = &cli.Command{
Usage: "displays the current wallet balance",
Description: "all wallet data is stored on Nostr relays, signed and encrypted with the given key, and reloaded again from relays on every call.\n\nthe same data can be accessed by other compatible nip60 clients.",
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{},
Flags: append(defaultKeyFlags,
&cli.BoolFlag{
Name: "stream",
Usage: "keep listening for wallet-related events and logging them",
@@ -499,13 +499,8 @@ var wallet = &cli.Command{
} else {
return fmt.Errorf("missing --private-key")
}
} else if sk := c.String("private-key"); sk != "" {
if !c.Bool("force") {
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
}
if err := w.SetPrivateKey(ctx, sk); err != nil {
return err
}
} else if sk := c.String("private-key"); sk != "" && !c.Bool("force") {
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
}
kr, _, _ := gatherKeyerFromArguments(ctx, c)
@@ -522,18 +517,13 @@ var wallet = &cli.Command{
info.ParseEvent(ie.Event)
}
if mints := c.StringSlice("mint"); len(mints) > 0 {
info.Mints = mints
} else if len(info.Mints) == 0 {
if mints := c.StringSlice("mints"); len(mints) == 0 && len(info.Mints) == 0 {
info.Mints = w.Mints
}
if len(info.Mints) == 0 {
return fmt.Errorf("missing --mint")
}
// the pubkey tag is mandatory, without it no one can send us nutzaps
info.PublicKey = nostr.PubKey(w.PublicKey.SerializeCompressed()[1:])
evt := nostr.Event{}
if err := info.ToEvent(ctx, kr, &evt); err != nil {
return err