mirror of
https://github.com/fiatjaf/nak.git
synced 2026-08-02 20:56:14 +00:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df857a076 | ||
|
|
52cf03d423 | ||
|
|
d0c291b25c | ||
|
|
6ee60cb197 | ||
|
|
c2eb2d7bdd | ||
|
|
168a182d50 | ||
|
|
f202f038a4 | ||
|
|
1043b2a7af | ||
|
|
9c3b4b69b6 | ||
|
|
1c542deb5e | ||
|
|
cefc0b91d0 | ||
|
|
8c97c7becb | ||
|
|
ada938c3a8 | ||
|
|
c2d74b846e | ||
|
|
06bd1ddb11 | ||
|
|
ebd93964de | ||
|
|
a285ac6723 | ||
|
|
e2f791ab85 | ||
|
|
ed5af14305 | ||
|
|
e5f4b07c3d | ||
|
|
b41260f663 | ||
|
|
c9e1511865 | ||
|
|
5af1d111ba | ||
|
|
2922a0de25 | ||
|
|
35d628ef6d | ||
|
|
a2f109c8c8 | ||
|
|
d9cc9f1d80 | ||
|
|
d97937eaa4 | ||
|
|
bdd3aa1093 | ||
|
|
02c12cb671 | ||
|
|
c04add227d | ||
|
|
c3f1ef7877 | ||
|
|
d0e719fb93 | ||
|
|
187842214e | ||
|
|
405be6efa9 | ||
|
|
47e462fe55 | ||
|
|
99ef7a8c91 | ||
|
|
a152d7f633 | ||
|
|
25d2045640 | ||
|
|
09b9939956 | ||
|
|
2a8cd898c2 | ||
|
|
eae828e03b | ||
|
|
9ae5798a24 | ||
|
|
5930437578 | ||
|
|
2193491c3b | ||
|
|
d3f4548dbd | ||
|
|
07b791e90a | ||
|
|
2c3d72b337 | ||
|
|
46c68462db | ||
|
|
0834420d89 | ||
|
|
217352ed0b | ||
|
|
318838b3ff | ||
|
|
b811396d8b | ||
|
|
2bc1cf3417 | ||
|
|
65ddf7b821 | ||
|
|
e19306b124 | ||
|
|
e059fba487 | ||
|
|
00f44958de | ||
|
|
e37ea23a8d | ||
|
|
3c4cb533b4 | ||
|
|
be26dfca3c | ||
|
|
26518ec260 | ||
|
|
de599c64f6 | ||
|
|
3d650ee502 | ||
|
|
67920ccbdd | ||
|
|
0dbe14aa93 | ||
|
|
ec1721cfe3 | ||
|
|
3b318fec54 | ||
|
|
82275e4b9e |
+72
-43
@@ -3,9 +3,11 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nipb0/blossom"
|
||||
@@ -19,7 +21,7 @@ var blossomCmd = &cli.Command{
|
||||
Usage: "an army knife for blossom things",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(defaultKeyFlags,
|
||||
&cli.StringFlag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "the hostname of the target mediaserver",
|
||||
@@ -41,7 +43,11 @@ var blossomCmd = &cli.Command{
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
|
||||
}
|
||||
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
|
||||
servers := c.StringSlice("server")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client = blossom.NewClient(servers[0], keyer.NewReadOnlySigner(pk))
|
||||
} else {
|
||||
var err error
|
||||
client, err = getBlossomClient(ctx, c)
|
||||
@@ -69,11 +75,17 @@ var blossomCmd = &cli.Command{
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "[files...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
servers := c.StringSlice("server")
|
||||
|
||||
if len(servers) == 0 {
|
||||
return fmt.Errorf("no server specified")
|
||||
}
|
||||
|
||||
if isPiped() {
|
||||
// get file from stdin
|
||||
if c.Args().Len() > 0 {
|
||||
@@ -84,34 +96,36 @@ var blossomCmd = &cli.Command{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read stdin: %w", err)
|
||||
}
|
||||
|
||||
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
} else {
|
||||
// get filenames from arguments
|
||||
hasError := false
|
||||
for _, fpath := range c.Args().Slice() {
|
||||
bd, err := client.UploadFilePath(ctx, fpath)
|
||||
for _, server := range servers {
|
||||
client := blossom.NewClient(server, keyer)
|
||||
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
hasError = true
|
||||
ctx = lineProcessingError(ctx, "failed to upload to '%s': %s", server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
}
|
||||
} else {
|
||||
// get filenames from arguments
|
||||
for _, fpath := range c.Args().Slice() {
|
||||
for _, server := range servers {
|
||||
client := blossom.NewClient(server, keyer)
|
||||
bd, err := client.UploadFilePath(ctx, fpath)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to upload '%s' to '%s': %s", fpath, server, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(3)
|
||||
j, _ := json.Marshal(bd)
|
||||
stdout(string(j))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -137,12 +151,24 @@ var blossomCmd = &cli.Command{
|
||||
outputs := c.StringSlice("output")
|
||||
|
||||
hasError := false
|
||||
for i, hash := range c.Args().Slice() {
|
||||
var hash [32]byte
|
||||
for i, hhash := range c.Args().Slice() {
|
||||
if len(hhash) != 64 {
|
||||
log("invalid blob hash '%s'\n", hhash)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(hhash), 64)); err != nil {
|
||||
log("invalid blob hash '%s': %s\n", hhash, err)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
if len(outputs)-1 >= i && outputs[i] != "--" {
|
||||
// save to this file
|
||||
err := client.DownloadToFile(ctx, hash, outputs[i])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
log("download failed for '%s': %s\n", hhash, err)
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
@@ -257,28 +283,27 @@ if any of the files are not found the command will fail, otherwise it will succe
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(out)
|
||||
return nil
|
||||
} else {
|
||||
for input := range getJsonsOrBlank() {
|
||||
if input == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to mirror '%s': %w", blobURL, err)
|
||||
continue
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(out)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
}
|
||||
|
||||
for input := range getJsonsOrBlank() {
|
||||
if input == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to mirror '%s': %s", blobURL, err)
|
||||
continue
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(out)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -290,5 +315,9 @@ func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blossom.NewClient(c.String("server"), keyer), nil
|
||||
servers := c.StringSlice("server")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blossom.NewClient(servers[0], keyer), nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -75,7 +76,7 @@ var bunker = &cli.Command{
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// read config from file
|
||||
config := BunkerConfig{}
|
||||
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||
baseRelaysUrls := nostr.AppendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||
for i, url := range baseRelaysUrls {
|
||||
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
||||
}
|
||||
@@ -153,7 +154,7 @@ var bunker = &cli.Command{
|
||||
for i, url := range config.Relays {
|
||||
config.Relays[i] = nostr.NormalizeURL(url)
|
||||
}
|
||||
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
||||
config.Relays = nostr.AppendUnique(config.Relays, baseRelaysUrls...)
|
||||
for _, bak := range baseAuthorizedKeys {
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||
@@ -224,17 +225,15 @@ var bunker = &cli.Command{
|
||||
}
|
||||
}
|
||||
}
|
||||
relayURLs := make([]string, 0, len(allRelays))
|
||||
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
|
||||
relays := connectToAllRelays(ctx, c, allRelays, nil)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
for _, relay := range relays {
|
||||
relayURLs = append(relayURLs, relay.URL)
|
||||
qs.Add("relay", relay.URL)
|
||||
for _, relay := range config.Relays {
|
||||
qs.Add("relay", relay)
|
||||
}
|
||||
if len(relayURLs) == 0 {
|
||||
if len(relays) == 0 {
|
||||
return fmt.Errorf("not connected to any relays: please specify at least one")
|
||||
}
|
||||
|
||||
@@ -251,8 +250,10 @@ var bunker = &cli.Command{
|
||||
|
||||
// this function will be called every now and then
|
||||
printBunkerInfo := func() {
|
||||
qs.Set("secret", newSecret)
|
||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
||||
iqs := make(url.Values)
|
||||
maps.Copy(iqs, qs)
|
||||
iqs.Set("secret", newSecret)
|
||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), iqs.Encode())
|
||||
|
||||
authorizedKeysStr := ""
|
||||
if len(config.Clients) != 0 {
|
||||
@@ -292,8 +293,8 @@ var bunker = &cli.Command{
|
||||
secretKeyFlag = "--sec " + sec
|
||||
}
|
||||
|
||||
relayURLsPossiblyWithoutSchema := make([]string, len(relayURLs))
|
||||
for i, url := range relayURLs {
|
||||
relayURLsPossiblyWithoutSchema := make([]string, len(config.Relays))
|
||||
for i, url := range config.Relays {
|
||||
if strings.HasPrefix(url, "wss://") {
|
||||
relayURLsPossiblyWithoutSchema[i] = url[6:]
|
||||
} else {
|
||||
@@ -310,7 +311,7 @@ var bunker = &cli.Command{
|
||||
)
|
||||
|
||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
|
||||
colors.bold(relayURLs),
|
||||
colors.bold(config.Relays),
|
||||
colors.bold(pubkey.Hex()),
|
||||
colors.bold(npub),
|
||||
authorizedKeysStr,
|
||||
@@ -321,7 +322,7 @@ var bunker = &cli.Command{
|
||||
} else {
|
||||
// otherwise just print the data
|
||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n\n",
|
||||
colors.bold(relayURLs),
|
||||
colors.bold(config.Relays),
|
||||
colors.bold(pubkey.Hex()),
|
||||
colors.bold(npub),
|
||||
authorizedKeysStr,
|
||||
@@ -340,7 +341,7 @@ var bunker = &cli.Command{
|
||||
printBunkerInfo()
|
||||
|
||||
// subscribe to relays
|
||||
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
|
||||
events := sys.Pool.SubscribeMany(ctx, allRelays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||
Since: nostr.Now(),
|
||||
@@ -452,54 +453,56 @@ var bunker = &cli.Command{
|
||||
for ie := range events {
|
||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||
|
||||
// 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) {
|
||||
continue
|
||||
}
|
||||
|
||||
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
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 := relayURLs
|
||||
for _, c := range config.Clients {
|
||||
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||
relays = c.CustomRelays
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
for _, c := range config.Clients {
|
||||
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||
relays = c.CustomRelays
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}()
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +20,48 @@ 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: 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 {
|
||||
relays := connectToAllRelays(ctx, c, relayUrls, nil, nostr.PoolOptions{})
|
||||
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")
|
||||
os.Exit(3)
|
||||
|
||||
@@ -120,8 +120,10 @@ func realCurl() error {
|
||||
keyFlags = append(keyFlags, arg)
|
||||
if arg != "--prompt-sec" {
|
||||
i++
|
||||
val := os.Args[i+2]
|
||||
keyFlags = append(keyFlags, val)
|
||||
if i+2 >= len(os.Args) {
|
||||
break
|
||||
}
|
||||
keyFlags = append(keyFlags, os.Args[i+2])
|
||||
}
|
||||
} else {
|
||||
curlFlags = append(curlFlags, arg)
|
||||
|
||||
@@ -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, nil, nostr.PoolOptions{})
|
||||
relayList := connectToAllRelays(ctx, c, relays, nil)
|
||||
if len(relayList) == 0 {
|
||||
return fmt.Errorf("no relays to use")
|
||||
}
|
||||
|
||||
@@ -2,13 +2,66 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func parseProfilePointerInput(target string) (nostr.ProfilePointer, bool) {
|
||||
var profilePtr nostr.ProfilePointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &profilePtr); err != nil || profilePtr.PublicKey == nostr.ZeroPK {
|
||||
return nostr.ProfilePointer{}, false
|
||||
}
|
||||
return profilePtr, true
|
||||
}
|
||||
|
||||
func parseEventPointerInput(target string) (nostr.EventPointer, bool) {
|
||||
var eventPtr nostr.EventPointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &eventPtr); err != nil || eventPtr.ID == nostr.ZeroID {
|
||||
return nostr.EventPointer{}, false
|
||||
}
|
||||
return eventPtr, true
|
||||
}
|
||||
|
||||
func parseEntityPointerInput(target string) (nostr.EntityPointer, bool) {
|
||||
var entityPtr nostr.EntityPointer
|
||||
if err := stdjson.Unmarshal([]byte(target), &entityPtr); err != nil || entityPtr.PublicKey == nostr.ZeroPK || entityPtr.Kind == 0 {
|
||||
return nostr.EntityPointer{}, false
|
||||
}
|
||||
return entityPtr, true
|
||||
}
|
||||
|
||||
func getEncodeSubcommandInput(args cli.Args, allowBlank bool) iter.Seq[string] {
|
||||
if args.Len() > 0 {
|
||||
return func(yield func(string) bool) {
|
||||
for _, arg := range args.Slice() {
|
||||
if !yield(arg) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(yield func(string) bool) {
|
||||
for jsonStr := range getJsonsOrBlank() {
|
||||
if jsonStr == "{}" {
|
||||
if allowBlank {
|
||||
yield("")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(jsonStr) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var encode = &cli.Command{
|
||||
Name: "encode",
|
||||
Usage: "encodes notes and other stuff to nip19 entities",
|
||||
@@ -21,14 +74,19 @@ var encode = &cli.Command{
|
||||
nak encode nsec <privkey-hex>
|
||||
echo '{"pubkey":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19","relays":["wss://nada.zero"]}' | nak encode
|
||||
echo '{
|
||||
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19"
|
||||
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19",
|
||||
"relays":["wss://nada.zero"],
|
||||
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
||||
} | nak encode`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 0 {
|
||||
return nil
|
||||
switch c.Args().First() {
|
||||
case "naddr", "nevent", "npub", "nprofile", "nsec":
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unknown encode target '%s'", c.Args().First())
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
@@ -45,21 +103,18 @@ var encode = &cli.Command{
|
||||
hasStdin = true
|
||||
}
|
||||
|
||||
var eventPtr nostr.EventPointer
|
||||
if err := json.Unmarshal([]byte(jsonStr), &eventPtr); err == nil && eventPtr.ID != nostr.ZeroID {
|
||||
stdout(nip19.EncodeNevent(eventPtr.ID, appendUnique(relays, eventPtr.Relays...), eventPtr.Author))
|
||||
if eventPtr, ok := parseEventPointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNevent(eventPtr.ID, nostr.AppendUnique(relays, eventPtr.Relays...), eventPtr.Author))
|
||||
continue
|
||||
}
|
||||
|
||||
var profilePtr nostr.ProfilePointer
|
||||
if err := json.Unmarshal([]byte(jsonStr), &profilePtr); err == nil && profilePtr.PublicKey != nostr.ZeroPK {
|
||||
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, appendUnique(relays, profilePtr.Relays...)))
|
||||
if entityPtr, ok := parseEntityPointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, nostr.AppendUnique(relays, entityPtr.Relays...)))
|
||||
continue
|
||||
}
|
||||
|
||||
var entityPtr nostr.EntityPointer
|
||||
if err := json.Unmarshal([]byte(jsonStr), &entityPtr); err == nil && entityPtr.PublicKey != nostr.ZeroPK {
|
||||
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, appendUnique(relays, entityPtr.Relays...)))
|
||||
if profilePtr, ok := parseProfilePointerInput(jsonStr); ok {
|
||||
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, nostr.AppendUnique(relays, profilePtr.Relays...)))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -82,7 +137,7 @@ var encode = &cli.Command{
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
pk, err := nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -101,7 +156,7 @@ var encode = &cli.Command{
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
sk, err := nostr.SecretKeyFromHex(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid private key '%s': %w", target, err)
|
||||
ctx = lineProcessingError(ctx, "invalid private key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -129,18 +184,25 @@ var encode = &cli.Command{
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
pk, err := nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for target := range getEncodeSubcommandInput(c.Args(), false) {
|
||||
relays := c.StringSlice("relay")
|
||||
pk := nostr.ZeroPK
|
||||
|
||||
if profilePtr, ok := parseProfilePointerInput(target); ok {
|
||||
pk = profilePtr.PublicKey
|
||||
relays = nostr.AppendUnique(relays, profilePtr.Relays...)
|
||||
} else {
|
||||
var err error
|
||||
pk, err = nostr.PubKeyFromHexCheap(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,19 +239,29 @@ var encode = &cli.Command{
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for target := range getStdinLinesOrArguments(c.Args()) {
|
||||
id, err := parseEventID(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
|
||||
continue
|
||||
}
|
||||
|
||||
for target := range getEncodeSubcommandInput(c.Args(), false) {
|
||||
id := nostr.ZeroID
|
||||
author := getPubKey(c, "author")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if eventPtr, ok := parseEventPointerInput(target); ok {
|
||||
id = eventPtr.ID
|
||||
relays = nostr.AppendUnique(relays, eventPtr.Relays...)
|
||||
if author == nostr.ZeroPK {
|
||||
author = eventPtr.Author
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
id, err = parseEventID(target)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,22 +281,19 @@ var encode = &cli.Command{
|
||||
Usage: "generate codes for addressable events",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
|
||||
Required: true,
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
|
||||
},
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "pubkey of the naddr author",
|
||||
Aliases: []string{"author", "a", "p"},
|
||||
Required: true,
|
||||
Name: "pubkey",
|
||||
Usage: "pubkey of the naddr author",
|
||||
Aliases: []string{"author", "a", "p"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "kind of referred replaceable event",
|
||||
Required: true,
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "kind of referred replaceable event",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
@@ -239,27 +308,55 @@ var encode = &cli.Command{
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for d := range getStdinLinesOrBlank() {
|
||||
for target := range getEncodeSubcommandInput(c.Args(), true) {
|
||||
pubkey := getPubKey(c, "pubkey")
|
||||
kind := nostr.Kind(c.Int("kind"))
|
||||
d := c.String("identifier")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
kind := c.Int("kind")
|
||||
if kind < 30000 || kind >= 40000 {
|
||||
return fmt.Errorf("kind must be between 30000 and 39999, got %d", kind)
|
||||
if entityPtr, ok := parseEntityPointerInput(target); ok {
|
||||
relays = nostr.AppendUnique(relays, entityPtr.Relays...)
|
||||
if pubkey == nostr.ZeroPK {
|
||||
pubkey = entityPtr.PublicKey
|
||||
}
|
||||
if kind == 0 {
|
||||
kind = entityPtr.Kind
|
||||
}
|
||||
if !c.IsSet("identifier") {
|
||||
d = entityPtr.Identifier
|
||||
}
|
||||
} else if target != "" {
|
||||
d = target
|
||||
}
|
||||
|
||||
if d == "" {
|
||||
d = c.String("identifier")
|
||||
if pubkey == nostr.ZeroPK {
|
||||
ctx = lineProcessingError(ctx, "pubkey must be set")
|
||||
continue
|
||||
}
|
||||
|
||||
if kind == 0 {
|
||||
ctx = lineProcessingError(ctx, "kind must be set")
|
||||
continue
|
||||
}
|
||||
|
||||
if kind.IsAddressable() {
|
||||
if d == "" {
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier must be set for addressable events")
|
||||
continue
|
||||
}
|
||||
} else if kind.IsReplaceable() {
|
||||
if d != "" {
|
||||
ctx = lineProcessingError(ctx, "\"d\" tag identifier must not be set for replaceable events")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
ctx = lineProcessingError(ctx, "can only encode addressable events")
|
||||
continue
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
relays = nostr.AppendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ example:
|
||||
Value: false,
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-sign",
|
||||
Usage: "print the event without signing it, using the specified pubkey",
|
||||
Value: false,
|
||||
Category: CATEGORY_SIGNER,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "pow",
|
||||
Usage: "nip13 difficulty to target when doing hash work on the event id",
|
||||
@@ -92,6 +98,12 @@ example:
|
||||
Usage: "print the nevent code (to stderr) after the event is published",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "publish to the \"write\" relays of the author and to the \"read\" relays of anyone mentioned in \"p\" tags",
|
||||
DefaultText: "false, will only use manually-specified relays",
|
||||
Category: CATEGORY_EXTRAS,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
@@ -100,6 +112,12 @@ example:
|
||||
Value: 0,
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&PubKeyOrAddressFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "set the event pubkey or add an 'a' tag if it's an address",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "content",
|
||||
Aliases: []string{"c"},
|
||||
@@ -129,6 +147,11 @@ example:
|
||||
Usage: "shortcut for --tag d=<value>",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "h",
|
||||
Usage: "shortcut for --tag h=<value>",
|
||||
Category: CATEGORY_EVENT_FIELDS,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "created-at",
|
||||
Aliases: []string{"time", "ts"},
|
||||
@@ -145,22 +168,7 @@ example:
|
||||
),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// try to connect to the relays here
|
||||
var relays []*nostr.Relay
|
||||
|
||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
||||
nostr.PoolOptions{
|
||||
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||
},
|
||||
},
|
||||
)
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
relayUrls := c.Args().Slice()
|
||||
|
||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
@@ -175,6 +183,11 @@ example:
|
||||
// this is called when we have a valid json from stdin
|
||||
handleEvent := func(stdinEvent string) error {
|
||||
evt.Content = ""
|
||||
evt.CreatedAt = 0
|
||||
clear(evt.Tags)
|
||||
evt.ID = nostr.ZeroID
|
||||
evt.PubKey = nostr.ZeroPK
|
||||
evt.Sig = [64]byte{}
|
||||
|
||||
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
|
||||
contentWasSupplied := strings.Contains(stdinEvent, `"content"`)
|
||||
@@ -247,12 +260,40 @@ example:
|
||||
tags = append(tags, nostr.Tag{"d", dtag})
|
||||
}
|
||||
}
|
||||
for _, htag := range c.StringSlice("h") {
|
||||
if tags.FindWithValue("h", htag) == nil {
|
||||
tags = append(tags, nostr.Tag{"h", htag})
|
||||
}
|
||||
}
|
||||
|
||||
var authorPubKey nostr.PubKey
|
||||
for _, a := range getPubKeyOrAddressSlice(c, "author") {
|
||||
// is it an address?
|
||||
if a.Addr != nil {
|
||||
aTag := a.Addr.AsTagReference()
|
||||
if tags.FindWithValue("a", aTag) == nil {
|
||||
tags = append(tags, nostr.Tag{"a", aTag})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// or is it an "author" pubkey?
|
||||
if a.PubKey != nostr.ZeroPK {
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
return fmt.Errorf("multiple author pubkeys provided")
|
||||
}
|
||||
authorPubKey = a.PubKey
|
||||
}
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
for _, tag := range tags {
|
||||
evt.Tags = append(evt.Tags, tag)
|
||||
}
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
evt.PubKey = authorPubKey
|
||||
}
|
||||
|
||||
if c.IsSet("created-at") {
|
||||
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||
@@ -277,7 +318,7 @@ example:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
} else if evt.PubKey == nostr.ZeroPK {
|
||||
evt.PubKey, _ = kr.GetPublicKey(ctx)
|
||||
}
|
||||
|
||||
@@ -288,7 +329,13 @@ example:
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
if evt.Sig == [64]byte{} || mustRehashAndResign {
|
||||
if c.Bool("no-sign") {
|
||||
if evt.PubKey == nostr.ZeroPK {
|
||||
return fmt.Errorf("--no-sign requires a pubkey in the event or via --author")
|
||||
}
|
||||
evt.ID = nostr.ZeroID
|
||||
evt.Sig = [64]byte{}
|
||||
} else if evt.Sig == [64]byte{} || mustRehashAndResign {
|
||||
if numSigners := c.Uint("musig"); numSigners > 1 {
|
||||
// must do musig
|
||||
pubkeys := c.StringSlice("musig-pubkey")
|
||||
@@ -313,6 +360,42 @@ example:
|
||||
}
|
||||
}
|
||||
|
||||
var relays []*nostr.Relay
|
||||
if len(relayUrls) > 0 || c.Bool("outbox") {
|
||||
if c.Bool("outbox") {
|
||||
if evt.PubKey != nostr.ZeroPK {
|
||||
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchWriteRelays(ctx, evt.PubKey)...)
|
||||
}
|
||||
|
||||
seenPubkeys := make(map[nostr.PubKey]struct{}, len(evt.Tags))
|
||||
for _, tag := range evt.Tags {
|
||||
if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") {
|
||||
continue
|
||||
}
|
||||
pk, err := nostr.PubKeyFromHex(tag[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenPubkeys[pk]; ok {
|
||||
continue
|
||||
}
|
||||
seenPubkeys[pk] = struct{}{}
|
||||
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchInboxRelays(ctx, pk, 15)...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(relayUrls) > 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// print event as json
|
||||
var result string
|
||||
if c.Bool("envelope") {
|
||||
@@ -349,7 +432,8 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
|
||||
if c.Bool("confirm") {
|
||||
relaysStr := make([]string, len(relays))
|
||||
for i, r := range relays {
|
||||
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
|
||||
_, after, _ := strings.Cut(r.URL, "://")
|
||||
relaysStr[i] = strings.ToLower(after)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
|
||||
|
||||
@@ -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,12 +21,18 @@ var fetch = &cli.Command{
|
||||
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
|
||||
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: append(reqFilterFlags,
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "also use these relays to fetch from",
|
||||
},
|
||||
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 {
|
||||
@@ -106,11 +115,30 @@ 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
|
||||
stdout(ie.Event)
|
||||
}
|
||||
|
||||
if !found {
|
||||
ctx = lineProcessingError(ctx, "no events found for %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
@@ -19,8 +19,13 @@ example:
|
||||
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
|
||||
`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: reqFilterFlags,
|
||||
ArgsUsage: "[event_json] [base_filter_json]",
|
||||
Flags: append(append([]cli.Flag{}, reqFilterFlags...),
|
||||
&cli.StringFlag{
|
||||
Name: "jq",
|
||||
Usage: "filter matching events with jq expression",
|
||||
},
|
||||
),
|
||||
ArgsUsage: "[event_json] [base_filter_json]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
|
||||
@@ -54,6 +59,11 @@ example:
|
||||
return err
|
||||
}
|
||||
|
||||
jq, err := jqPrepare(c.String("jq"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if there is no stdin we'll still get an empty object here
|
||||
for evtj := range getJsonsOrBlank() {
|
||||
var evt nostr.Event
|
||||
@@ -83,7 +93,20 @@ example:
|
||||
}
|
||||
|
||||
if baseFilter.Matches(evt) {
|
||||
stdout(evt)
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = evt.String()
|
||||
} else {
|
||||
v, matches, err := jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
out, _ = json.MarshalToString(v)
|
||||
}
|
||||
stdout(out)
|
||||
} else {
|
||||
logverbose("event %s didn't match %s", evt, baseFilter)
|
||||
}
|
||||
|
||||
@@ -183,6 +183,66 @@ func getPubKeySlice(cmd *cli.Command, name string) []nostr.PubKey {
|
||||
//
|
||||
//
|
||||
|
||||
type PubKeyOrAddress struct {
|
||||
PubKey nostr.PubKey
|
||||
Addr *nostr.EntityPointer
|
||||
}
|
||||
|
||||
type (
|
||||
pubKeyOrAddressValue struct {
|
||||
value PubKeyOrAddress
|
||||
hasBeenSet bool
|
||||
}
|
||||
pubKeyOrAddressSlice = cli.SliceBase[PubKeyOrAddress, struct{}, pubKeyOrAddressValue]
|
||||
PubKeyOrAddressFlag = cli.FlagBase[[]PubKeyOrAddress, struct{}, pubKeyOrAddressSlice]
|
||||
)
|
||||
|
||||
var _ cli.ValueCreator[PubKeyOrAddress, struct{}] = pubKeyOrAddressValue{}
|
||||
|
||||
func (t pubKeyOrAddressValue) Create(val PubKeyOrAddress, p *PubKeyOrAddress, c struct{}) cli.Value {
|
||||
*p = val
|
||||
return &pubKeyOrAddressValue{
|
||||
value: val,
|
||||
}
|
||||
}
|
||||
|
||||
func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string {
|
||||
if b.Addr != nil {
|
||||
return b.Addr.AsTagReference()
|
||||
}
|
||||
return b.PubKey.String()
|
||||
}
|
||||
|
||||
func (t *pubKeyOrAddressValue) Set(value string) error {
|
||||
pubkey, err1 := parsePubKey(value)
|
||||
if err1 == nil {
|
||||
t.value = PubKeyOrAddress{PubKey: pubkey}
|
||||
t.hasBeenSet = true
|
||||
return nil
|
||||
}
|
||||
|
||||
addr, err2 := nostr.ParseAddrString(value)
|
||||
if err2 == nil {
|
||||
t.value = PubKeyOrAddress{Addr: &addr}
|
||||
t.hasBeenSet = true
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("value is neither a pubkey or an address: %w; %w", err1, err2)
|
||||
}
|
||||
|
||||
func (t *pubKeyOrAddressValue) String() string { return fmt.Sprintf("%#v", t.value) }
|
||||
func (t *pubKeyOrAddressValue) Value() PubKeyOrAddress { return t.value }
|
||||
func (t *pubKeyOrAddressValue) Get() any { return t.value }
|
||||
|
||||
func getPubKeyOrAddressSlice(cmd *cli.Command, name string) []PubKeyOrAddress {
|
||||
return cmd.Value(name).([]PubKeyOrAddress)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type (
|
||||
IDFlag = cli.FlagBase[nostr.ID, struct{}, idValue]
|
||||
)
|
||||
|
||||
@@ -239,6 +239,10 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
||||
}
|
||||
|
||||
if !seal.VerifySignature() {
|
||||
return fmt.Errorf("seal signature is invalid")
|
||||
}
|
||||
|
||||
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
|
||||
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
|
||||
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
|
||||
@@ -278,6 +282,9 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
return fmt.Errorf("failed to decrypt rumor: %w", err)
|
||||
}
|
||||
|
||||
rumor.PubKey = seal.PubKey
|
||||
rumor.ID = rumor.GetID()
|
||||
|
||||
// output the unwrapped event (rumor)
|
||||
stdout(rumor.String())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip34"
|
||||
"fiatjaf.com/nostr/nip34/gitnaturalapi"
|
||||
"fiatjaf.com/nostr/nip34/grasp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -76,7 +79,7 @@ aside from those, there is also:
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "maintainers",
|
||||
Usage: "maintainer public keys as npub or hex (can be used multiple times)",
|
||||
Usage: "maintainer public keys as npub, nip05 or hex (can be used multiple times)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "earliest-unique-commit",
|
||||
@@ -112,20 +115,23 @@ aside from those, there is also:
|
||||
defaultOwner = existingConfig.Owner
|
||||
} else {
|
||||
// extract info from nostr:// git remotes (this is just for migrating from ngit)
|
||||
if output, err := exec.Command("git", "remote", "-v").Output(); err == nil {
|
||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, remote := range remotes {
|
||||
if strings.Contains(remote, "nostr://") {
|
||||
parts := strings.Fields(remote)
|
||||
if len(parts) >= 2 {
|
||||
nostrURL := parts[1]
|
||||
// parse nostr://npub.../relay_hostname/identifier
|
||||
if remoteOwner, remoteIdentifier, relays, err := parseRepositoryAddress(ctx, nostrURL); err == nil && len(relays) > 0 {
|
||||
defaultIdentifier = remoteIdentifier
|
||||
defaultOwner = nip19.EncodeNpub(remoteOwner)
|
||||
}
|
||||
}
|
||||
output, err := exec.Command("git", "remote", "-v").Output()
|
||||
if err == nil {
|
||||
for _, remote := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||
if !strings.Contains(remote, "nostr://") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(remote)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
// parse nostr://npub.../relay_hostname/identifier
|
||||
remoteOwner, remoteIdentifier, relays, err := parseRepositoryAddress(ctx, parts[1])
|
||||
if err != nil || len(relays) == 0 {
|
||||
continue
|
||||
}
|
||||
defaultIdentifier = remoteIdentifier
|
||||
defaultOwner = nip19.EncodeNpub(remoteOwner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +172,7 @@ aside from those, there is also:
|
||||
} else if c.Bool("interactive") {
|
||||
for {
|
||||
if err := survey.AskOne(&survey.Input{
|
||||
Message: "owner (npub or hex)",
|
||||
Message: "owner (npub, nip05 or hex)",
|
||||
Default: defaultOwner,
|
||||
}, &ownerStr); err != nil {
|
||||
return err
|
||||
@@ -248,7 +254,13 @@ aside from those, there is also:
|
||||
config.Owner = getValue(existingConfig.Owner, c.String("owner"), config.Owner)
|
||||
config.GraspServers = getSliceValue(existingConfig.GraspServers, c.StringSlice("grasp-servers"), config.GraspServers)
|
||||
config.EarliestUniqueCommit = getValue(existingConfig.EarliestUniqueCommit, c.String("earliest-unique-commit"), config.EarliestUniqueCommit)
|
||||
config.Maintainers = getSliceValue(existingConfig.Maintainers, c.StringSlice("maintainers"), config.Maintainers)
|
||||
maintainers := getSliceValue(existingConfig.Maintainers, c.StringSlice("maintainers"), config.Maintainers)
|
||||
config.Maintainers = make([]string, 0, len(maintainers))
|
||||
for _, m := range maintainers {
|
||||
if pubkey, err := parsePubKey(m); err == nil {
|
||||
config.Maintainers = append(config.Maintainers, pubkey.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bool("interactive") {
|
||||
// prompt for name
|
||||
@@ -272,7 +284,7 @@ aside from those, there is also:
|
||||
"gitnostr.com",
|
||||
"relay.ngit.dev",
|
||||
"pyramid.fiatjaf.com",
|
||||
"git.shakespeare.dyi",
|
||||
"git.shakespeare.diy",
|
||||
}, graspServerHost, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -305,7 +317,7 @@ aside from those, there is also:
|
||||
return err
|
||||
}
|
||||
|
||||
// Prompt for maintainers
|
||||
// prompt for maintainers
|
||||
maintainers, err := promptForStringList("maintainers", config.Maintainers, []string{}, nil, func(s string) bool {
|
||||
pk, err := parsePubKey(s)
|
||||
if err != nil {
|
||||
@@ -360,7 +372,7 @@ aside from those, there is also:
|
||||
{
|
||||
Name: "clone",
|
||||
Usage: "clone a NIP-34 repository from a nostr:// URI",
|
||||
Description: `the <repository> parameter maybe in the form "<npub, hex, nprofile or nip05>/<identifier>", ngit-style like "nostr://<npub>/<relay>/<identifier>" or an "naddr1..." code.`,
|
||||
Description: `the <repository> parameter maybe in the form "<npub, hex, nprofile or nip05>/<identifier>", ngit-style like "nostr://<npub>/<relay>/<identifier>" or "nostr://<npub>/<identifier>" or an "naddr1..." code.`,
|
||||
ArgsUsage: "<repository> [directory]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args()
|
||||
@@ -459,6 +471,190 @@ aside from those, there is also:
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "download",
|
||||
Usage: "download a file from a NIP-34 repository",
|
||||
ArgsUsage: "<repository> <path>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"O"},
|
||||
Usage: "output path (use '-' for stdout)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ref",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "git ref/tag/branch/commit to read from",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args()
|
||||
if args.Len() < 2 {
|
||||
return fmt.Errorf("missing repository and path")
|
||||
}
|
||||
|
||||
repo := args.Get(0)
|
||||
path := args.Get(1)
|
||||
outputPath := c.String("output")
|
||||
ref := strings.TrimSpace(c.String("ref"))
|
||||
|
||||
if outputPath == "" {
|
||||
cleaned := strings.TrimRight(path, "/")
|
||||
base := filepath.Base(cleaned)
|
||||
if base == "." || base == "/" || base == "" {
|
||||
return fmt.Errorf("cannot determine output filename from path '%s', use --output", path)
|
||||
}
|
||||
outputPath = base
|
||||
}
|
||||
|
||||
if outputPath != "-" {
|
||||
if fi, err := os.Stat(outputPath); err == nil && fi.IsDir() {
|
||||
return fmt.Errorf("output path '%s' is a directory", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
var gitURLs []string
|
||||
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
|
||||
gitURLs = []string{strings.TrimRight(repo, "/")}
|
||||
} else {
|
||||
owner, identifier, relayHints, err := parseRepositoryAddress(ctx, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse repository address '%s': %w", repo, err)
|
||||
}
|
||||
|
||||
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
||||
if err != nil {
|
||||
var stateErr *StateErr
|
||||
if ref == "" || !errors.As(err, &stateErr) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ref == "" && state != nil && state.HEAD != "" {
|
||||
ref = state.HEAD
|
||||
}
|
||||
|
||||
for _, url := range repo.Clone {
|
||||
if strings.HasPrefix(url, "http") {
|
||||
gitURLs = append(gitURLs, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(gitURLs) == 0 {
|
||||
return fmt.Errorf("no HTTP git URLs found for repository")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, url := range gitURLs {
|
||||
if lastErr != nil {
|
||||
log("%s\n", color.HiRedString(lastErr.Error()))
|
||||
}
|
||||
lastErr = nil
|
||||
|
||||
{
|
||||
printUrl := color.BlueString(url)
|
||||
if grasp.IsGraspURL(url) {
|
||||
printUrl = color.HiYellowString(strings.Split(url, "/")[2])
|
||||
}
|
||||
log("attempting download from %s... ", printUrl)
|
||||
}
|
||||
|
||||
info, err := gitnaturalapi.GetInfoRefs(url)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
var commitHash string
|
||||
|
||||
if ref == "" {
|
||||
if symref, ok := info.Symrefs["HEAD"]; ok && symref != "" {
|
||||
commitHash, _ = info.Refs[symref]
|
||||
} else if head, ok := info.Refs["HEAD"]; ok && head != "" {
|
||||
commitHash = head
|
||||
} else {
|
||||
lastErr = fmt.Errorf("could not resolve default ref for %s", url)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if gitHashRe.MatchString(ref) {
|
||||
commitHash = ref
|
||||
} else if strings.HasPrefix(ref, "refs/") {
|
||||
if ch, ok := info.Refs[ref]; ok {
|
||||
commitHash = ch
|
||||
}
|
||||
} else {
|
||||
if ch, ok := info.Refs["refs/heads/"+ref]; ok {
|
||||
commitHash = ch
|
||||
} else if ch, ok := info.Refs["refs/tags/"+ref]; ok {
|
||||
commitHash = ch
|
||||
} else if sr, ok := info.Symrefs[ref]; ok && ch != "" {
|
||||
commitHash, _ = info.Refs[sr]
|
||||
}
|
||||
}
|
||||
|
||||
if commitHash == "" {
|
||||
lastErr = fmt.Errorf("couldn't get a commit hash for ref '%s'", ref)
|
||||
continue
|
||||
}
|
||||
|
||||
if !gitHashRe.MatchString(commitHash) {
|
||||
lastErr = fmt.Errorf("couldn't invalid commit hash for ref '%s': '%s'", ref, commitHash)
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := gitnaturalapi.GetObjectByPath(url, commitHash, path)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if entry == nil {
|
||||
lastErr = fmt.Errorf("path '%s' not found", path)
|
||||
continue
|
||||
}
|
||||
if entry.IsDir {
|
||||
lastErr = fmt.Errorf("path '%s' is a directory", path)
|
||||
continue
|
||||
}
|
||||
|
||||
obj, err := gitnaturalapi.GetObject(url, entry.Hash)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("download error: %s", err)
|
||||
continue
|
||||
}
|
||||
if obj == nil {
|
||||
lastErr = fmt.Errorf("object for '%s' not found", path)
|
||||
continue
|
||||
}
|
||||
if obj.Type != gitnaturalapi.ObjectTypeBlob {
|
||||
lastErr = fmt.Errorf("object at '%s' is not a file", path)
|
||||
continue
|
||||
}
|
||||
|
||||
if outputPath == "-" {
|
||||
if _, err = os.Stdout.Write(obj.Data); err != nil {
|
||||
log("\nprinted object %s to stdout\n", color.CyanString(obj.Hash))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, obj.Data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
log("\nsaved object %s to %s\n", color.CyanString(obj.Hash), color.GreenString(outputPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
log("%s\n", color.HiRedString(lastErr.Error()))
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to download '%s' from '%s'", path, repo)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "push",
|
||||
Usage: "push git changes",
|
||||
@@ -1769,7 +1965,10 @@ func ensureGitRepositoryMaintainer(ctx context.Context, kr nostr.Keyer, repo nip
|
||||
return pubkey, nil
|
||||
}
|
||||
|
||||
var patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`)
|
||||
var (
|
||||
patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`)
|
||||
gitHashRe = regexp.MustCompile(`^[0-9a-f]{7,64}$`)
|
||||
)
|
||||
|
||||
func patchSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
|
||||
for _, line := range strings.Split(evt.Content, "\n") {
|
||||
@@ -2228,7 +2427,7 @@ func fetchRepositoryAndState(
|
||||
relayHints []string,
|
||||
) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
||||
// fetch repository announcement (30617)
|
||||
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
||||
relays := nostr.AppendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{30617},
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
@@ -2437,30 +2636,40 @@ func parseRepositoryAddress(
|
||||
}
|
||||
|
||||
// format 2: nostr://<npub_or_nip05>/<relay_hostname>/<identifier> (ngit-style)
|
||||
// format 2b: nostr://<npub_or_nip05>/<identifier> (without relay)
|
||||
if strings.HasPrefix(address, "nostr://") {
|
||||
parts := strings.Split(address, "/")
|
||||
if len(parts) != 5 {
|
||||
if len(parts) == 5 {
|
||||
// nostr://<owner>/<relay>/<identifier>
|
||||
owner, err = parsePubKey(parts[2])
|
||||
if err != nil {
|
||||
return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err)
|
||||
}
|
||||
|
||||
relayHost := parts[3]
|
||||
identifier = parts[4]
|
||||
|
||||
if strings.HasPrefix(relayHost, "wss:") || strings.HasPrefix(relayHost, "ws:") {
|
||||
relayHints = []string{relayHost}
|
||||
} else {
|
||||
relayHints = []string{"wss://" + relayHost}
|
||||
}
|
||||
|
||||
return owner, identifier, relayHints, nil
|
||||
} else if len(parts) == 4 {
|
||||
// nostr://<owner>/<identifier>
|
||||
owner, err = parsePubKey(parts[2])
|
||||
if err != nil {
|
||||
return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err)
|
||||
}
|
||||
|
||||
identifier = parts[3]
|
||||
return owner, identifier, nil, nil
|
||||
} else {
|
||||
return nostr.PubKey{}, "", nil, fmt.Errorf(
|
||||
"invalid nostr URL format, expected nostr://<npub|nip05>/<relay_hostname>/<identifier>, got: %s", address,
|
||||
"invalid nostr URL format, expected nostr://<npub|nip05>/<identifier> or nostr://<npub|nip05>/<relay>/<identifier>, got: %s", address,
|
||||
)
|
||||
}
|
||||
|
||||
owner, err = parsePubKey(parts[2])
|
||||
if err != nil {
|
||||
return nostr.PubKey{}, "", nil, fmt.Errorf("invalid owner in URL: %w", err)
|
||||
}
|
||||
|
||||
relayHost := parts[3]
|
||||
identifier = parts[4]
|
||||
|
||||
// construct relay hint from hostname
|
||||
if strings.HasPrefix(relayHost, "wss:") || strings.HasPrefix(relayHost, "ws:") {
|
||||
relayHints = []string{relayHost}
|
||||
} else {
|
||||
relayHints = []string{"wss://" + relayHost}
|
||||
}
|
||||
|
||||
return owner, identifier, relayHints, nil
|
||||
}
|
||||
|
||||
// format 3: <npub, hex, nprofile or nip05>/<identifier>
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a
|
||||
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
|
||||
@@ -17,7 +17,6 @@ require (
|
||||
github.com/mark3labs/mcp-go v0.8.3
|
||||
github.com/markusmobius/go-dateparser v1.2.3
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mattn/go-tty v0.0.7
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
@@ -29,8 +28,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.6
|
||||
fiatjaf.com/lib v0.3.7
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||
github.com/itchyny/gojq v0.12.19
|
||||
github.com/mattn/go-tty/v2 v2.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -54,6 +55,8 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/chzyer/logex v1.1.10 // indirect
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
@@ -69,6 +72,7 @@ require (
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hablullah/go-hijri v1.0.2 // indirect
|
||||
github.com/hablullah/go-juliandays v1.0.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.8 // indirect
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
@@ -76,7 +80,7 @@ require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -102,7 +106,7 @@ require (
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
|
||||
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a h1:lor1LcOjMUNZi5hafyXMmTz5J2kTrvS5I0hZMy3jOuU=
|
||||
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a/go.mod h1:iRKV8eYKzePA30MdbaYBpAv8pYQ6to8rDr3W+R2hJzM=
|
||||
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-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=
|
||||
@@ -83,6 +83,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||
@@ -151,6 +155,10 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
|
||||
github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY=
|
||||
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
|
||||
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
|
||||
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -190,10 +198,10 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=
|
||||
github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-tty/v2 v2.0.0 h1:AgXDfbKcENaiQbjdQ+o2ZusbtiaOhK1ArFD75d5U6qk=
|
||||
github.com/mattn/go-tty/v2 v2.0.0/go.mod h1:azVwsnH46TUJRQPRp/IrUT+8nRkkvjvkESJJxAA4Y48=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
@@ -322,8 +330,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
|
||||
@@ -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,7 +28,28 @@ 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: defaultKeyFlags,
|
||||
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{
|
||||
{
|
||||
Name: "info",
|
||||
@@ -254,56 +276,32 @@ var group = &cli.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
// stored events arrive newest-first before EOSE, so we buffer them
|
||||
// from the end and print them in chronological order once EOSE
|
||||
// arrives; live events are printed as they come.
|
||||
eosed := false
|
||||
messages := make([]struct {
|
||||
message string
|
||||
rendered bool
|
||||
}, 200)
|
||||
messages := make([]string, 200)
|
||||
base := len(messages)
|
||||
|
||||
tryRender := func(i int) {
|
||||
// if all messages before these are loaded we can render this,
|
||||
// otherwise we render whatever we can and stop
|
||||
for m, msg := range messages[base:] {
|
||||
if msg.rendered {
|
||||
continue
|
||||
}
|
||||
if msg.message == "" {
|
||||
break
|
||||
}
|
||||
messages[base+m].rendered = true
|
||||
stdout(msg.message)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case evt := <-sub.Events:
|
||||
var i int
|
||||
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||
line := color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
|
||||
if eosed {
|
||||
i = len(messages)
|
||||
messages = append(messages, struct {
|
||||
message string
|
||||
rendered bool
|
||||
}{})
|
||||
} else {
|
||||
stdout(line)
|
||||
} else if base > 0 {
|
||||
base--
|
||||
i = base
|
||||
messages[base] = line
|
||||
}
|
||||
|
||||
go func() {
|
||||
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||
messages[i].message = color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
|
||||
|
||||
if eosed {
|
||||
tryRender(i)
|
||||
}
|
||||
}()
|
||||
// else: pre-EOSE buffer is full (relay returned more than the limit); drop.
|
||||
case reason := <-sub.ClosedReason:
|
||||
stdout("closed:" + color.YellowString(reason))
|
||||
case <-sub.EndOfStoredEvents:
|
||||
eosed = true
|
||||
tryRender(len(messages) - 1)
|
||||
for _, msg := range messages[base:] {
|
||||
stdout(msg)
|
||||
}
|
||||
case <-sub.Context.Done():
|
||||
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
|
||||
}
|
||||
@@ -626,7 +624,7 @@ write your forum post
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9000, func(evt *nostr.Event, args []string) error {
|
||||
return publishModerationEvent(ctx, c, 9000, func(evt *nostr.Event, args []string) error {
|
||||
pubkey := getPubKey(c, "pubkey")
|
||||
tag := nostr.Tag{"p", pubkey.Hex()}
|
||||
tag = append(tag, c.StringSlice("role")...)
|
||||
@@ -646,13 +644,23 @@ write your forum post
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9001, func(evt *nostr.Event, args []string) error {
|
||||
return publishModerationEvent(ctx, c, 9001, func(evt *nostr.Event, args []string) error {
|
||||
pubkey := getPubKey(c, "pubkey")
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"p", pubkey.Hex()})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create-group",
|
||||
Usage: "creates a new group",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return publishModerationEvent(ctx, c, 9007, func(evt *nostr.Event, args []string) error {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit-metadata",
|
||||
Usage: "edits the group metadata",
|
||||
@@ -771,19 +779,15 @@ write your forum post
|
||||
group.SupportedKinds = nil
|
||||
}
|
||||
|
||||
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
|
||||
return publishModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"name", group.Name})
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"picture", group.Picture})
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"about", group.About})
|
||||
if group.Restricted {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"restricted"})
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
|
||||
}
|
||||
if group.Closed {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
|
||||
}
|
||||
if group.Hidden {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
|
||||
@@ -792,13 +796,9 @@ write your forum post
|
||||
}
|
||||
if group.Private {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
|
||||
}
|
||||
if group.LiveKit {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"livekit"})
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"no-livekit"})
|
||||
}
|
||||
if group.SupportedKinds != nil {
|
||||
tag := make(nostr.Tag, 1, 1+len(group.SupportedKinds))
|
||||
@@ -823,7 +823,7 @@ write your forum post
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9005, func(evt *nostr.Event, args []string) error {
|
||||
return publishModerationEvent(ctx, c, 9005, func(evt *nostr.Event, args []string) error {
|
||||
id := getID(c, "event")
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"e", id.Hex()})
|
||||
return nil
|
||||
@@ -835,7 +835,7 @@ write your forum post
|
||||
Usage: "deletes the group",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9008, func(evt *nostr.Event, args []string) error {
|
||||
return publishModerationEvent(ctx, c, 9008, func(evt *nostr.Event, args []string) error {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -851,7 +851,7 @@ write your forum post
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9009, func(evt *nostr.Event, args []string) error {
|
||||
return publishModerationEvent(ctx, c, 9009, func(evt *nostr.Event, args []string) error {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"code", c.String("code")})
|
||||
return nil
|
||||
})
|
||||
@@ -860,7 +860,7 @@ write your forum post
|
||||
},
|
||||
}
|
||||
|
||||
func createModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind, setupFunc func(*nostr.Event, []string) error) error {
|
||||
func publishModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind, setupFunc func(*nostr.Event, []string) error) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("requires group identifier")
|
||||
@@ -945,11 +945,10 @@ func fetchGroupMetadata(ctx context.Context, relay string, identifier string) (n
|
||||
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
|
||||
return group, err
|
||||
}
|
||||
|
||||
break
|
||||
return group, nil
|
||||
}
|
||||
|
||||
return group, nil
|
||||
return group, fmt.Errorf("couldn't fetch group metadata")
|
||||
}
|
||||
|
||||
func fetchGroupForumTopics(ctx context.Context, relay string, identifier string) ([]nostr.RelayEvent, error) {
|
||||
|
||||
+9
-31
@@ -7,8 +7,6 @@ import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -29,7 +27,7 @@ import (
|
||||
"github.com/fatih/color"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/mattn/go-tty"
|
||||
"github.com/mattn/go-tty/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -188,23 +186,17 @@ func connectToAllRelays(
|
||||
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
|
||||
opts nostr.PoolOptions,
|
||||
) []*nostr.Relay {
|
||||
// first pass to check if these are valid relay URLs
|
||||
for _, url := range relayUrls {
|
||||
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
|
||||
for i, url := range relayUrls {
|
||||
url = nostr.NormalizeURL(url)
|
||||
relayUrls[i] = url
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
log("invalid relay URL: %s\n", url)
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
|
||||
opts.EventMiddleware = sys.TrackEventHints
|
||||
opts.PenaltyBox = true
|
||||
opts.RelayOptions = nostr.RelayOptions{
|
||||
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}},
|
||||
}
|
||||
sys.Pool = nostr.NewPool(opts)
|
||||
|
||||
relays := make([]*nostr.Relay, 0, len(relayUrls))
|
||||
|
||||
if supportsDynamicMultilineMagic() {
|
||||
@@ -249,7 +241,7 @@ func connectToAllRelays(
|
||||
} else {
|
||||
// simple flow
|
||||
for _, url := range relayUrls {
|
||||
log("connecting to %s... ", color.CyanString(url))
|
||||
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
|
||||
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
|
||||
if relay != nil {
|
||||
relays = append(relays, relay)
|
||||
@@ -434,19 +426,6 @@ func clampError(err error, prefixAlreadyPrinted int) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
func appendUnique[A comparable](list []A, newEls ...A) []A {
|
||||
ex:
|
||||
for _, newEl := range newEls {
|
||||
for _, el := range list {
|
||||
if el == newEl {
|
||||
continue ex
|
||||
}
|
||||
}
|
||||
list = append(list, newEl)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func askConfirmation(msg string) bool {
|
||||
if isPiped() {
|
||||
tty, err := tty.Open()
|
||||
@@ -491,15 +470,14 @@ func askConfirmation(msg string) bool {
|
||||
}
|
||||
|
||||
func parsePubKey(value string) (nostr.PubKey, error) {
|
||||
// try nip05 first
|
||||
if nip05.IsValidIdentifier(value) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
pp, err := nip05.QueryIdentifier(ctx, value)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return pp.PublicKey, nil
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, err
|
||||
}
|
||||
// if nip05 fails, fall through to try as pubkey
|
||||
return pp.PublicKey, nil
|
||||
}
|
||||
|
||||
pk, err := nostr.PubKeyFromHex(value)
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
||||
"fiatjaf.com/nostr/nip49"
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/fatih/color"
|
||||
"github.com/mattn/go-tty"
|
||||
"github.com/mattn/go-tty/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/itchyny/gojq"
|
||||
)
|
||||
|
||||
const eventJQPrelude = `
|
||||
def tags(tagName): .tags | map(select(.[0] == tagName));
|
||||
def tag(tagName): tags(tagName) | .[0];
|
||||
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;
|
||||
`
|
||||
|
||||
type jqProcessor func(nostr.Event) (any, bool, error)
|
||||
|
||||
func jqPrepare(expr string) (jqProcessor, error) {
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query, err := gojq.Parse(eventJQPrelude + expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
}
|
||||
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile jq expression: %w", err)
|
||||
}
|
||||
|
||||
return func(evt nostr.Event) (any, bool, error) {
|
||||
input, err := toJQInput(evt)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
iter := code.Run(input)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
return v, false, nil
|
||||
}
|
||||
|
||||
if err, ok := v.(error); ok {
|
||||
return v, false, err
|
||||
}
|
||||
|
||||
if jqTruthy(v) {
|
||||
return v, true, nil
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toJQInput(v any) (any, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal jq input: %w", err)
|
||||
}
|
||||
|
||||
var input any
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal jq input: %w", err)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func jqTruthy(v any) bool {
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return v
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@ var key = &cli.Command{
|
||||
Commands: []*cli.Command{
|
||||
generate,
|
||||
public,
|
||||
expand,
|
||||
encryptKey,
|
||||
decryptKey,
|
||||
combine,
|
||||
validate,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -67,6 +69,37 @@ var public = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var expand = &cli.Command{
|
||||
Name: "expand",
|
||||
Usage: "left-pads a hex key to 64 characters",
|
||||
Description: ``,
|
||||
ArgsUsage: "[key]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for keyhex := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
|
||||
keyhex = strings.TrimSpace(keyhex)
|
||||
if keyhex == "" {
|
||||
continue
|
||||
}
|
||||
if len(keyhex) > 64 {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: length %d", len(keyhex))
|
||||
continue
|
||||
}
|
||||
check := keyhex
|
||||
if len(check)%2 == 1 {
|
||||
check = "0" + check
|
||||
}
|
||||
if _, err := hex.DecodeString(check); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
}
|
||||
keyhex = strings.ToLower(keyhex)
|
||||
stdout(strings.Repeat("0", 64-len(keyhex)) + keyhex)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var encryptKey = &cli.Command{
|
||||
Name: "encrypt",
|
||||
Usage: "encrypts a secret key and prints an ncryptsec code",
|
||||
@@ -155,6 +188,53 @@ var decryptKey = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var validate = &cli.Command{
|
||||
Name: "validate",
|
||||
Usage: "validates a public key by attempting to parse it",
|
||||
Description: `Accepts public keys in hex (64 characters) or npub format.
|
||||
Returns error if key is invalid, otherwise exits successfully.`,
|
||||
ArgsUsage: "[pubkey...]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for pk := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
|
||||
pk = strings.TrimSpace(pk)
|
||||
if pk == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var pkBytes []byte
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(pk, "npub1") {
|
||||
_, data, err := nip19.Decode(pk)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
|
||||
continue
|
||||
}
|
||||
tmp := data.([32]byte)
|
||||
pkBytes = tmp[:]
|
||||
} else {
|
||||
pkBytes, err = hex.DecodeString(pk)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex: %s", err)
|
||||
continue
|
||||
}
|
||||
if len(pkBytes) != 32 {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey length: expected 32 bytes, got %d", len(pkBytes))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
_, err = btcec.ParsePubKey(append([]byte{0x02}, pkBytes...))
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var combine = &cli.Command{
|
||||
Name: "combine",
|
||||
Usage: "combines two or more pubkeys using musig2",
|
||||
@@ -183,15 +263,13 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
|
||||
|
||||
for i, keyhex := range result.Keys {
|
||||
keyb, err := hex.DecodeString(keyhex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
}
|
||||
pk32, err := parsePubKey(keyhex)
|
||||
if err == nil { /* we'll try both the 02 and the 03 prefix versions */
|
||||
result.Keys[i] = pk32.Hex()
|
||||
|
||||
if len(keyb) == 32 /* we'll use both the 02 and the 03 prefix versions */ {
|
||||
group := make([]*btcec.PublicKey, 2)
|
||||
for i, prefix := range []byte{0x02, 0x03} {
|
||||
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
|
||||
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, pk32[:]...))
|
||||
if err != nil {
|
||||
log("error parsing key %s: %s", keyhex, err)
|
||||
continue
|
||||
@@ -199,7 +277,16 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
group[i] = pubk
|
||||
}
|
||||
keyGroups = append(keyGroups, group)
|
||||
} else /* assume it's 33 */ {
|
||||
continue
|
||||
}
|
||||
|
||||
keyb, err := hex.DecodeString(keyhex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
}
|
||||
if len(keyb) == 33 {
|
||||
result.Keys[i] = hex.EncodeToString(keyb[1:])
|
||||
|
||||
pubk, err := btcec.ParsePubKey(keyb)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
|
||||
@@ -208,6 +295,8 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
|
||||
|
||||
// remove the leading byte from the output just so it is all uniform
|
||||
result.Keys[i] = result.Keys[i][2:]
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,28 +16,30 @@ import (
|
||||
|
||||
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
|
||||
configPath := c.String("config-path")
|
||||
if configPath != "" {
|
||||
hintsPath := filepath.Join(configPath, "outbox/hints")
|
||||
os.MkdirAll(hintsPath, 0755)
|
||||
_, err := lmdbh.NewLMDBHints(hintsPath)
|
||||
if err != nil {
|
||||
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
|
||||
}
|
||||
if configPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configPath, "events")
|
||||
os.MkdirAll(eventsPath, 0755)
|
||||
sys.Store = &lmdb.LMDBBackend{Path: eventsPath}
|
||||
if err := sys.Store.Init(); err != nil {
|
||||
log("failed to create boltdb events db at '%s': %s\n", eventsPath, err)
|
||||
sys.Store = &nullstore.NullStore{}
|
||||
}
|
||||
hintsPath := filepath.Join(configPath, "outbox/hints")
|
||||
os.MkdirAll(hintsPath, 0755)
|
||||
_, err := lmdbh.NewLMDBHints(hintsPath)
|
||||
if err != nil {
|
||||
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
|
||||
}
|
||||
|
||||
kvPath := filepath.Join(configPath, "kvstore")
|
||||
os.MkdirAll(kvPath, 0755)
|
||||
if kv, err := lmdbkv.NewStore(kvPath); err != nil {
|
||||
log("failed to create boltdb kvstore db at '%s': %s\n", kvPath, err)
|
||||
} else {
|
||||
sys.KVStore = kv
|
||||
}
|
||||
eventsPath := filepath.Join(configPath, "events")
|
||||
os.MkdirAll(eventsPath, 0755)
|
||||
sys.Store = &lmdb.LMDBBackend{Path: eventsPath}
|
||||
if err := sys.Store.Init(); err != nil {
|
||||
log("failed to create boltdb events db at '%s': %s\n", eventsPath, err)
|
||||
sys.Store = &nullstore.NullStore{}
|
||||
}
|
||||
|
||||
kvPath := filepath.Join(configPath, "kvstore")
|
||||
os.MkdirAll(kvPath, 0755)
|
||||
if kv, err := lmdbkv.NewStore(kvPath); err != nil {
|
||||
log("failed to create boltdb kvstore db at '%s': %s\n", kvPath, err)
|
||||
} else {
|
||||
sys.KVStore = kv
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,14 @@ var app = &cli.Command{
|
||||
curl,
|
||||
fsCmd,
|
||||
publish,
|
||||
nsite,
|
||||
git,
|
||||
group,
|
||||
nip,
|
||||
syncCmd,
|
||||
spell,
|
||||
profile,
|
||||
validateCmd,
|
||||
},
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
@@ -103,13 +105,11 @@ var app = &cli.Command{
|
||||
|
||||
setupLocalDatabases(c, sys)
|
||||
|
||||
sys.Pool = nostr.NewPool(nostr.PoolOptions{
|
||||
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
|
||||
EventMiddleware: sys.TrackEventHints,
|
||||
RelayOptions: nostr.RelayOptions{
|
||||
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
|
||||
},
|
||||
})
|
||||
sys.Pool.QueryMiddleware = sys.TrackQueryAttempts
|
||||
sys.Pool.EventMiddleware = sys.TrackEventHints
|
||||
sys.Pool.RelayOptions = nostr.RelayOptions{
|
||||
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
},
|
||||
@@ -120,6 +120,12 @@ func init() {
|
||||
Name: "version",
|
||||
Usage: "prints the version",
|
||||
}
|
||||
cli.HelpFlag = &cli.BoolFlag{
|
||||
Name: "help",
|
||||
Usage: "shows help",
|
||||
HideDefault: true,
|
||||
Local: true,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip5a"
|
||||
"fiatjaf.com/nostr/nipb0/blossom"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var nsite = &cli.Command{
|
||||
Name: "nsite",
|
||||
Suggest: true,
|
||||
Usage: "publishes and downloads nip-5A static sites",
|
||||
ArgsUsage: "<directory> [relay...]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: defaultKeyFlags,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "upload",
|
||||
Usage: "uploads site files and publishes manifest event",
|
||||
ArgsUsage: "<directory> [relay...]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "root",
|
||||
Usage: "publish root site as kind 15128",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "identifier",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "publish named site as kind 35128 with this d tag",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "description",
|
||||
Usage: "a human-readable description of the site",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "source",
|
||||
Usage: "a link to the source code of the site",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "blossom server hostname or URL, can be given multiple times",
|
||||
DefaultText: "defaults to the publisher's list of preferred blossom servers",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "yes",
|
||||
Aliases: []string{"y"},
|
||||
Usage: "skip upload confirmation prompt",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
dir := c.Args().First()
|
||||
if dir == "" {
|
||||
return fmt.Errorf("missing directory")
|
||||
}
|
||||
|
||||
st, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat %s: %w", dir, err)
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
|
||||
root := c.Bool("root")
|
||||
identifier := c.String("identifier")
|
||||
if root == (identifier != "") {
|
||||
return fmt.Errorf("pick exactly one of --root or --identifier/-d")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
manifest := nip5a.SiteManifest{
|
||||
Pubkey: pk,
|
||||
Root: root,
|
||||
Identifier: identifier,
|
||||
Paths: make(map[string][32]byte),
|
||||
Description: c.String("description"),
|
||||
Source: c.String("source"),
|
||||
}
|
||||
|
||||
blossomServers := c.StringSlice("server")
|
||||
if len(blossomServers) != 0 {
|
||||
manifest.Servers = blossomServers
|
||||
} else {
|
||||
servers := sys.FetchBlossomServerList(ctx, pk)
|
||||
if len(servers.Items) == 0 {
|
||||
return fmt.Errorf("no blossom servers advertised in manifest or kind:10063")
|
||||
}
|
||||
blossomServers = make([]string, len(servers.Items))
|
||||
for i, s := range servers.Items {
|
||||
blossomServers[i] = s.Value()
|
||||
}
|
||||
}
|
||||
|
||||
if !c.Bool("yes") {
|
||||
log("%s\n", color.CyanString("files:"))
|
||||
|
||||
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
log(" %s\n", color.GreenString("/%s", filepath.ToSlash(relPath)))
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log("%s\n", color.CyanString("blossom servers:"))
|
||||
for _, server := range blossomServers {
|
||||
log(" %s\n", color.YellowString(server))
|
||||
}
|
||||
if !askConfirmation("upload nsite and publish manifest? [y/n] ") {
|
||||
return fmt.Errorf("aborted")
|
||||
}
|
||||
}
|
||||
|
||||
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
var hhash string
|
||||
for _, server := range blossomServers {
|
||||
client := blossom.NewClient(server, kr)
|
||||
bd, err := client.UploadFilePath(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload %s to %s: %w", path, server, err)
|
||||
}
|
||||
hhash = bd.SHA256
|
||||
log("uploaded %s to %s as %s\n", color.GreenString(path), color.YellowString(server), color.CyanString(hhash))
|
||||
}
|
||||
|
||||
var hash [32]byte
|
||||
if _, err := hex.Decode(hash[:], []byte(hhash)); err != nil {
|
||||
return fmt.Errorf("invalid blob hash '%s': %w", hhash, err)
|
||||
}
|
||||
manifest.Paths["/"+filepath.ToSlash(relPath)] = hash
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt := manifest.ToEvent()
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("error signing manifest event: %w", err)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
stdout(nip19.EncodeNpub(pk))
|
||||
} else {
|
||||
stdout(nip5a.PubKeyToBase36(pk) + identifier)
|
||||
}
|
||||
|
||||
return publishFlow(ctx, c, kr, evt, relays)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "download",
|
||||
Usage: "downloads all files from a published nsite",
|
||||
ArgsUsage: "<site> [directory]",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
input := c.Args().First()
|
||||
if input == "" {
|
||||
return fmt.Errorf("missing site")
|
||||
}
|
||||
|
||||
outputDir := c.Args().Get(1)
|
||||
if outputDir == "" {
|
||||
return fmt.Errorf("missing write directory")
|
||||
}
|
||||
if st, err := os.Stat(outputDir); err == nil {
|
||||
if st.IsDir() {
|
||||
return fmt.Errorf("output directory %s already exists", outputDir)
|
||||
}
|
||||
return fmt.Errorf("output path %s already exists and is not a directory", outputDir)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to stat output directory %s: %w", outputDir, err)
|
||||
}
|
||||
|
||||
pk, identifier, isRoot, err := nip5a.DecodeSiteURL(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := nostr.Filter{
|
||||
Authors: []nostr.PubKey{pk},
|
||||
Limit: 1,
|
||||
}
|
||||
if isRoot {
|
||||
filter.Kinds = []nostr.Kind{nostr.KindNsiteRoot}
|
||||
} else {
|
||||
filter.Kinds = []nostr.Kind{nostr.KindNsiteNamed}
|
||||
filter.Tags = nostr.TagMap{"d": []string{identifier}}
|
||||
}
|
||||
|
||||
res := sys.Pool.QuerySingle(ctx, sys.FetchWriteRelays(ctx, pk), filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-nsite",
|
||||
})
|
||||
if res == nil {
|
||||
return fmt.Errorf("failed to fetch nsite with filter %v", filter)
|
||||
}
|
||||
|
||||
mnf, err := nip5a.ParseSiteManifest(&res.Event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid nsite %s: %w", res.Event, err)
|
||||
}
|
||||
|
||||
blossomServers := mnf.Servers
|
||||
if len(blossomServers) == 0 {
|
||||
servers := sys.FetchBlossomServerList(ctx, res.Event.PubKey)
|
||||
if len(servers.Items) == 0 {
|
||||
return fmt.Errorf("no blossom servers advertised in manifest or kind:10063")
|
||||
}
|
||||
blossomServers = make([]string, len(servers.Items))
|
||||
for i, s := range servers.Items {
|
||||
blossomServers[i] = s.Value()
|
||||
}
|
||||
}
|
||||
|
||||
signer := keyer.NewReadOnlySigner(pk)
|
||||
|
||||
for path, hash := range mnf.Paths {
|
||||
relPath := strings.TrimPrefix(path, "/")
|
||||
if !filepath.IsLocal(relPath) {
|
||||
return fmt.Errorf("manifest path %q escapes output directory", path)
|
||||
}
|
||||
fullPath := filepath.Join(outputDir, filepath.FromSlash(relPath))
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create %s: %w", filepath.Dir(fullPath), err)
|
||||
}
|
||||
|
||||
var downloadErr error
|
||||
for _, server := range blossomServers {
|
||||
client := blossom.NewClient(server, signer)
|
||||
data, err := client.Download(ctx, hash)
|
||||
if err != nil {
|
||||
downloadErr = err
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", fullPath, err)
|
||||
}
|
||||
stdout(path)
|
||||
downloadErr = nil
|
||||
break
|
||||
}
|
||||
if downloadErr != nil {
|
||||
return fmt.Errorf("failed to download '%s': %w", path, downloadErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
+7
-7
@@ -91,6 +91,8 @@ example:
|
||||
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)
|
||||
@@ -152,13 +154,11 @@ example:
|
||||
relayUrls = nostr.AppendUnique(relayUrls, targetRelays...)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, replyRelays...)
|
||||
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
|
||||
relays := connectToAllRelays(ctx, c, relayUrls, nil,
|
||||
nostr.PoolOptions{
|
||||
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -24,7 +24,7 @@ var relay = &cli.Command{
|
||||
|
||||
info, err := nip11.Fetch(ctx, url)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %w", url, err)
|
||||
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %s", url, err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ example:
|
||||
DisableSliceFlagSeparator: true,
|
||||
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",
|
||||
@@ -60,7 +68,7 @@ example:
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "outbox",
|
||||
Usage: "use outbox relays from specified public keys",
|
||||
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{
|
||||
@@ -100,6 +108,10 @@ example:
|
||||
),
|
||||
ArgsUsage: "[relay...]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Bool("no-verify") {
|
||||
sys.Pool.RelayOptions.AssumeValid = true
|
||||
}
|
||||
|
||||
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
|
||||
if negentropy {
|
||||
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
|
||||
@@ -125,6 +137,14 @@ example:
|
||||
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
|
||||
}
|
||||
|
||||
jq, err := jqPrepare(c.String("jq"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jq != nil && len(relayUrls) == 0 && !c.Bool("outbox") {
|
||||
return fmt.Errorf("--jq requires relay URLs or --outbox")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -132,25 +152,25 @@ example:
|
||||
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,
|
||||
nostr.PoolOptions{
|
||||
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)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// stop here already if all connections failed
|
||||
if len(relays) == 0 {
|
||||
@@ -195,8 +215,11 @@ example:
|
||||
if err := easyjson.Unmarshal([]byte(scanner.Text()), &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := store.SaveEvent(evt); err != nil || err == eventstore.ErrDupEvent {
|
||||
continue
|
||||
if err := store.SaveEvent(evt); err != nil {
|
||||
if err == eventstore.ErrDupEvent {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to save event from sync file: %w", err)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
@@ -206,6 +229,7 @@ example:
|
||||
|
||||
target := PrintingQuerierPublisher{
|
||||
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
|
||||
jq: jq,
|
||||
}
|
||||
|
||||
var source nostr.Querier = nil
|
||||
@@ -235,7 +259,9 @@ example:
|
||||
}
|
||||
}
|
||||
} else {
|
||||
performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req")
|
||||
if err := performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req", jq); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter or spell
|
||||
@@ -277,7 +303,8 @@ func performReq(
|
||||
paginate bool,
|
||||
paginateInterval time.Duration,
|
||||
label string,
|
||||
) {
|
||||
jq jqProcessor,
|
||||
) error {
|
||||
var results chan nostr.RelayEvent
|
||||
var closeds chan nostr.RelayClosed
|
||||
|
||||
@@ -291,6 +318,24 @@ func performReq(
|
||||
} else if outbox {
|
||||
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
|
||||
|
||||
pTagPubkeys := make([]nostr.PubKey, 0, 4)
|
||||
if len(filter.Authors) == 0 && filter.Tags != nil {
|
||||
seen := make(map[nostr.PubKey]struct{})
|
||||
pTags := append([]string{}, filter.Tags["p"]...)
|
||||
pTags = append(pTags, filter.Tags["P"]...)
|
||||
for _, value := range pTags {
|
||||
pubkey, err := parsePubKey(value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[pubkey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pubkey] = struct{}{}
|
||||
pTagPubkeys = append(pTagPubkeys, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, relayUrl := range relayUrls {
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
@@ -298,54 +343,96 @@ func performReq(
|
||||
})
|
||||
}
|
||||
|
||||
// relays for each pubkey
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
|
||||
for _, pubkey := range filter.Authors {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
idx := slices.IndexFunc(defs, matchUrl)
|
||||
if idx == -1 {
|
||||
// new relay, add it
|
||||
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
|
||||
if len(filter.Authors) == 0 && len(pTagPubkeys) > 0 {
|
||||
// relays for p tags when no authors are given
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering inbox relays for %d p-tags...\n", len(pTagPubkeys))
|
||||
for _, pubkey := range pTagPubkeys {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchInboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
// otherwise we'll just use the idx
|
||||
mu.Unlock()
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
idx := slices.IndexFunc(defs, matchUrl)
|
||||
if idx == -1 {
|
||||
// new relay, add it
|
||||
mu.Lock()
|
||||
idx = slices.IndexFunc(defs, matchUrl)
|
||||
if idx == -1 {
|
||||
defs = append(defs, nostr.DirectedFilter{
|
||||
Filter: filter,
|
||||
Relay: url,
|
||||
})
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// existing relay, add this pubkey
|
||||
defs[idx].Authors = append(defs[idx].Authors, pubkey)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
errg.Wait()
|
||||
} else {
|
||||
// relays for each pubkey
|
||||
errg := errgroup.Group{}
|
||||
errg.SetLimit(16)
|
||||
mu := sync.Mutex{}
|
||||
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
|
||||
for _, pubkey := range filter.Authors {
|
||||
errg.Go(func() error {
|
||||
n := int(outboxRelaysPerPubKey)
|
||||
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
|
||||
if slices.Contains(relayUrls, url) {
|
||||
// already specified globally, ignore
|
||||
continue
|
||||
}
|
||||
if !nostr.IsValidRelayURL(url) {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
|
||||
idx := slices.IndexFunc(defs, matchUrl)
|
||||
if idx == -1 {
|
||||
// new relay, add it
|
||||
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()
|
||||
}
|
||||
|
||||
// existing relay, add this pubkey
|
||||
defs[idx].Authors = append(defs[idx].Authors, pubkey)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
errg.Wait()
|
||||
}
|
||||
errg.Wait()
|
||||
|
||||
if stream {
|
||||
logverbose("running subscription with %d directed filters...\n", len(defs))
|
||||
@@ -371,7 +458,22 @@ readevents:
|
||||
if !stillOpen {
|
||||
break readevents
|
||||
}
|
||||
stdout(ie.Event)
|
||||
|
||||
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, _ = json.MarshalToString(v)
|
||||
}
|
||||
stdout(out)
|
||||
|
||||
case closed, stillOpen := <-closeds:
|
||||
if stillOpen {
|
||||
if closed.HandledAuth {
|
||||
@@ -384,10 +486,12 @@ readevents:
|
||||
break readevents
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var reqFilterFlags = []cli.Flag{
|
||||
&PubKeySliceFlag{
|
||||
&PubKeyOrAddressFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "only accept events from these authors",
|
||||
@@ -426,6 +530,11 @@ var reqFilterFlags = []cli.Flag{
|
||||
Usage: "shortcut for --tag d=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "h",
|
||||
Usage: "shortcut for --tag h=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "since",
|
||||
Aliases: []string{"s"},
|
||||
@@ -451,10 +560,30 @@ var reqFilterFlags = []cli.Flag{
|
||||
},
|
||||
}
|
||||
|
||||
type flagTag struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
|
||||
filter.Authors = append(filter.Authors, authors...)
|
||||
tags := make([]flagTag, 0, 5)
|
||||
|
||||
if as := getPubKeyOrAddressSlice(c, "author"); len(as) > 0 {
|
||||
for _, author := range as {
|
||||
|
||||
// is it an address?
|
||||
if author.Addr != nil {
|
||||
tags = append(tags, flagTag{"a", author.Addr.AsTagReference()})
|
||||
continue
|
||||
}
|
||||
|
||||
// or is it an "author" pubkey?
|
||||
if author.PubKey != nostr.ZeroPK {
|
||||
filter.Authors = append(filter.Authors, author.PubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ids := getIDSlice(c, "id"); len(ids) > 0 {
|
||||
filter.IDs = append(filter.IDs, ids...)
|
||||
}
|
||||
@@ -464,27 +593,30 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
if search := c.String("search"); search != "" {
|
||||
filter.Search = search
|
||||
}
|
||||
tags := make([][]string, 0, 5)
|
||||
|
||||
for _, tagFlag := range c.StringSlice("tag") {
|
||||
spl := strings.SplitN(tagFlag, "=", 2)
|
||||
if len(spl) == 2 {
|
||||
val := spl[1]
|
||||
if len(spl) == 1 {
|
||||
val = decodeTagValue(val, []rune(spl[0])[0])
|
||||
if len(spl[0]) == 1 {
|
||||
val = decodeTagValue(val, rune(spl[0][0]))
|
||||
}
|
||||
tags = append(tags, []string{spl[0], val})
|
||||
tags = append(tags, flagTag{spl[0], val})
|
||||
} else {
|
||||
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
||||
}
|
||||
}
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
tags = append(tags, []string{"e", decodeTagValue(etag, 'e')})
|
||||
tags = append(tags, flagTag{"e", decodeTagValue(etag, 'e')})
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
tags = append(tags, []string{"p", decodeTagValue(ptag, 'p')})
|
||||
tags = append(tags, flagTag{"p", decodeTagValue(ptag, 'p')})
|
||||
}
|
||||
for _, dtag := range c.StringSlice("d") {
|
||||
tags = append(tags, []string{"d", dtag})
|
||||
tags = append(tags, flagTag{"d", dtag})
|
||||
}
|
||||
for _, htag := range c.StringSlice("h") {
|
||||
tags = append(tags, flagTag{"h", htag})
|
||||
}
|
||||
|
||||
if len(tags) > 0 && filter.Tags == nil {
|
||||
@@ -492,10 +624,10 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if _, ok := filter.Tags[tag[0]]; !ok {
|
||||
filter.Tags[tag[0]] = make([]string, 0, 3)
|
||||
if _, ok := filter.Tags[tag.key]; !ok {
|
||||
filter.Tags[tag.key] = make([]string, 0, 3)
|
||||
}
|
||||
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
||||
filter.Tags[tag.key] = append(filter.Tags[tag.key], tag.value)
|
||||
}
|
||||
|
||||
if c.IsSet("since") {
|
||||
@@ -516,11 +648,25 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
|
||||
|
||||
type PrintingQuerierPublisher struct {
|
||||
nostr.QuerierPublisher
|
||||
jq jqProcessor
|
||||
}
|
||||
|
||||
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
|
||||
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
|
||||
stdout(evt)
|
||||
var out string
|
||||
if p.jq == nil {
|
||||
out = evt.String()
|
||||
} else {
|
||||
v, matches, err := p.jq(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jq filter failed: %w", err)
|
||||
}
|
||||
if !matches {
|
||||
return nil
|
||||
}
|
||||
out, _ = json.MarshalToString(v)
|
||||
}
|
||||
stdout(out)
|
||||
return nil
|
||||
} else if err == eventstore.ErrDupEvent {
|
||||
return nil
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -105,13 +108,65 @@ var serve = &cli.Command{
|
||||
rl.Negentropy = true
|
||||
}
|
||||
|
||||
started := make(chan bool)
|
||||
exited := make(chan error)
|
||||
|
||||
hostname := c.String("hostname")
|
||||
port := int(c.Uint("port"))
|
||||
|
||||
totalConnections := atomic.Int32{}
|
||||
rl.OnConnect = func(ctx context.Context) {
|
||||
totalConnections.Add(1)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
totalConnections.Add(-1)
|
||||
}()
|
||||
}
|
||||
|
||||
d := debounce.New(time.Second * 2)
|
||||
var printStatus func()
|
||||
printStatus = func() {
|
||||
d(func() {
|
||||
totalEvents, err := db.CountEvents(nostr.Filter{})
|
||||
if err != nil {
|
||||
log("failed to count: %s\n", err)
|
||||
}
|
||||
subs := rl.GetListeningFilters()
|
||||
|
||||
blossomMsg := ""
|
||||
if c.Bool("blossom") {
|
||||
blobsStored := blobStore.Size()
|
||||
blossomMsg = fmt.Sprintf("blobs: %s, ",
|
||||
color.HiMagentaString("%d", blobsStored),
|
||||
)
|
||||
}
|
||||
|
||||
graspMsg := ""
|
||||
if c.Bool("grasp") {
|
||||
gitAnnounced := 0
|
||||
gitStored := 0
|
||||
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
|
||||
gitAnnounced++
|
||||
identifier := evt.Tags.GetD()
|
||||
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
|
||||
gitStored++
|
||||
}
|
||||
}
|
||||
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
|
||||
color.HiMagentaString("%d", gitAnnounced),
|
||||
color.HiMagentaString("%d", gitStored),
|
||||
)
|
||||
}
|
||||
|
||||
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
|
||||
color.HiMagentaString("•"),
|
||||
color.HiMagentaString("%d", totalEvents),
|
||||
blossomMsg,
|
||||
graspMsg,
|
||||
color.HiMagentaString("%d", totalConnections.Load()),
|
||||
color.HiMagentaString("%d", len(subs)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if c.Bool("blossom") {
|
||||
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
|
||||
@@ -162,9 +217,13 @@ var serve = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(hostname, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := rl.Start(hostname, port, started)
|
||||
exited <- err
|
||||
exited <- http.Serve(ln, rl)
|
||||
}()
|
||||
|
||||
// relay logging
|
||||
@@ -191,61 +250,6 @@ var serve = &cli.Command{
|
||||
return false, ""
|
||||
}
|
||||
|
||||
totalConnections := atomic.Int32{}
|
||||
rl.OnConnect = func(ctx context.Context) {
|
||||
totalConnections.Add(1)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
totalConnections.Add(-1)
|
||||
}()
|
||||
}
|
||||
|
||||
d := debounce.New(time.Second * 2)
|
||||
printStatus = func() {
|
||||
d(func() {
|
||||
totalEvents, err := db.CountEvents(nostr.Filter{})
|
||||
if err != nil {
|
||||
log("failed to count: %s\n", err)
|
||||
}
|
||||
subs := rl.GetListeningFilters()
|
||||
|
||||
blossomMsg := ""
|
||||
if c.Bool("blossom") {
|
||||
blobsStored := blobStore.Size()
|
||||
blossomMsg = fmt.Sprintf("blobs: %s, ",
|
||||
color.HiMagentaString("%d", blobsStored),
|
||||
)
|
||||
}
|
||||
|
||||
graspMsg := ""
|
||||
if c.Bool("grasp") {
|
||||
gitAnnounced := 0
|
||||
gitStored := 0
|
||||
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
|
||||
gitAnnounced++
|
||||
identifier := evt.Tags.GetD()
|
||||
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
|
||||
gitStored++
|
||||
}
|
||||
}
|
||||
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
|
||||
color.HiMagentaString("%d", gitAnnounced),
|
||||
color.HiMagentaString("%d", gitStored),
|
||||
)
|
||||
}
|
||||
|
||||
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
|
||||
color.HiMagentaString("•"),
|
||||
color.HiMagentaString("%d", totalEvents),
|
||||
blossomMsg,
|
||||
graspMsg,
|
||||
color.HiMagentaString("%d", totalConnections.Load()),
|
||||
color.HiMagentaString("%d", len(subs)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
<-started
|
||||
log("%s relay running at %s", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
|
||||
if c.Bool("grasp") {
|
||||
log(" (grasp repos at %s)", repoDir)
|
||||
|
||||
@@ -248,7 +248,9 @@ func runSpell(
|
||||
|
||||
// execute
|
||||
logSpellDetails(spell)
|
||||
performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell")
|
||||
if err := performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ func parseMessageBuildNext(
|
||||
skipCallback(getBoundKey(bound))
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.SkipMode))
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.SkipMode))
|
||||
}
|
||||
|
||||
case negentropy.FingerprintMode:
|
||||
@@ -420,7 +420,7 @@ func parseMessageBuildNext(
|
||||
|
||||
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
|
||||
nextMsg.Write(acc.Buf[0:negentropy.FingerprintSize] /* idem */)
|
||||
}
|
||||
case negentropy.IdListMode:
|
||||
@@ -452,7 +452,7 @@ func parseMessageBuildNext(
|
||||
fingerprint := acc.GetFingerprint(numIds)
|
||||
|
||||
bw.WriteBound(nextMsg, bound)
|
||||
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
|
||||
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
|
||||
nextMsg.Write(fingerprint[:])
|
||||
}
|
||||
default:
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/schema"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var validateCmd = &cli.Command{
|
||||
Name: "validate",
|
||||
Usage: "validates events against the provided RoK YAML schema",
|
||||
Description: `takes a URL to a YAML schema in the same format as that of https://github.com/nostr-protocol/registry-of-kinds (defaults to that one) and validates the event tags and content against it, according to its kind.
|
||||
|
||||
example:
|
||||
nak event -k 1 -p not_a_pubkey | nak validate
|
||||
>> schema validation failed: tag[0][1]: invalid pubkey value 'not_a_pubkey' at tag 'p', index 1: pubkey should be 64-char hex, got 'not_a_pubkey'
|
||||
`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
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 {
|
||||
var validator schema.Validator
|
||||
|
||||
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{}
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
return fmt.Errorf("invalid event JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := validator.ValidateEvent(evt); err != nil {
|
||||
return fmt.Errorf("schema validation failed: %w", err)
|
||||
}
|
||||
|
||||
stdout(evt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for stdinEvent := range getJsonsOrBlank() {
|
||||
if stdinEvent == "" {
|
||||
for _, arg := range c.Args().Slice() {
|
||||
if err := handleEvent(arg); err != nil {
|
||||
ctx = lineProcessingError(ctx, "%s", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handleEvent(stdinEvent); err != nil {
|
||||
ctx = lineProcessingError(ctx, "%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
@@ -27,23 +28,35 @@ it outputs nothing if the verification is successful.`,
|
||||
|
||||
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid event: %s", err)
|
||||
logverbose("<>: invalid event.\n", evt.ID.Hex())
|
||||
logverbose("%s\n", color.RedString("<>: invalid event."))
|
||||
continue
|
||||
}
|
||||
|
||||
if evt.GetID() != evt.ID {
|
||||
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
|
||||
logverbose("%s: invalid id.\n", evt.ID.Hex())
|
||||
impliedID := evt.GetID()
|
||||
idsMatch := impliedID == evt.ID
|
||||
logverbose(
|
||||
"%s\n%s %s\n%s %s\n%s %s\n%s %s\n%s %s\n",
|
||||
color.CyanString("verifying event:"),
|
||||
color.BlueString(" event: "), stdinEvent,
|
||||
color.BlueString(" given id: "), color.YellowString("%s", evt.ID),
|
||||
color.BlueString(" serialized:"), string(evt.Serialize()),
|
||||
color.BlueString(" implied id:"), color.YellowString("%s", impliedID),
|
||||
color.BlueString(" ids match: "), color.New(map[bool]color.Attribute{true: color.FgGreen, false: color.FgRed}[idsMatch]).Sprint(idsMatch),
|
||||
)
|
||||
|
||||
if impliedID != evt.ID {
|
||||
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", impliedID, evt.ID)
|
||||
logverbose("%s\n", color.RedString("invalid id: %s", evt.ID.Hex()))
|
||||
continue
|
||||
}
|
||||
|
||||
if !evt.VerifySignature() {
|
||||
ctx = lineProcessingError(ctx, "invalid signature")
|
||||
logverbose("%s: invalid signature.\n", evt.ID.Hex())
|
||||
logverbose("%s\n", color.RedString("invalid signature: %s", evt.ID.Hex()))
|
||||
continue
|
||||
}
|
||||
|
||||
logverbose("%s: valid.\n", evt.ID.Hex())
|
||||
logverbose("%s\n", color.GreenString("valid: %s", evt.ID.Hex()))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
|
||||
Reference in New Issue
Block a user