mirror of
https://github.com/fiatjaf/nak.git
synced 2026-08-03 21:16:15 +00:00
Compare commits
108
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 | ||
|
|
ae98e99ec6 | ||
|
|
29db04b7ea | ||
|
|
59fecacbdc | ||
|
|
faac4d9440 | ||
|
|
f00a4a7d2a | ||
|
|
d823ac55a6 | ||
|
|
105ccef7ec | ||
|
|
9d4df21836 | ||
|
|
952d638ac3 | ||
|
|
c1e72e0af1 | ||
|
|
5f4efdbc69 | ||
|
|
4967db13a1 | ||
|
|
da0b753371 | ||
|
|
bef67d35d2 | ||
|
|
a8fb2e4189 | ||
|
|
7aea4cf9a1 | ||
|
|
d472efe707 | ||
|
|
17341b3af6 | ||
|
|
ff5a7b4ba7 | ||
|
|
7596e317b8 | ||
|
|
da7fc3fac3 | ||
|
|
0e8475b388 | ||
|
|
5532c884bc | ||
|
|
05857ab190 | ||
|
|
bf22a63404 | ||
|
|
6bd2d1cdfc | ||
|
|
9c8d59c5c5 | ||
|
|
5957c08d15 | ||
|
|
f59c8a670d | ||
|
|
61a3b89d08 | ||
|
|
d61fdc4cb4 | ||
|
|
0735ded0fc | ||
|
|
5233a77510 | ||
|
|
fd34cc7c5e | ||
|
|
2d151c2ac8 | ||
|
|
037e8efcc6 | ||
|
|
1b380dea9a | ||
|
|
f126b3f7ee | ||
|
|
dc5ffe5129 |
+3
-3
@@ -1,8 +1,8 @@
|
||||
# build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# install git and ca-certificates (needed for fetching dependencies)
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
RUN apk add --no-cache git ca-certificates gcc musl-dev
|
||||
|
||||
# set working directory
|
||||
WORKDIR /app
|
||||
@@ -19,7 +19,7 @@ COPY . .
|
||||
# build the application
|
||||
# use cgo_enabled=0 to create a static binary
|
||||
# use -ldflags to strip debug info and reduce binary size
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o nak .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-w -s" -o nak .
|
||||
|
||||
# runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
+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)
|
||||
}
|
||||
@@ -117,16 +118,16 @@ var bunker = &cli.Command{
|
||||
|
||||
persist = func() {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
log(color.RedString("failed to persist: %w\n"), err)
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
log(color.RedString("failed to persist: %w\n"), err)
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
log(color.RedString("failed to persist: %w\n"), err)
|
||||
log(color.RedString("failed to persist: %s\n"), err)
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -418,6 +419,13 @@ var bunker = &cli.Command{
|
||||
return true
|
||||
}
|
||||
if slices.Contains(authorizedSecrets, secret) {
|
||||
// add client to authorized list for subsequent requests
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -445,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()
|
||||
}
|
||||
}()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -623,21 +633,21 @@ func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
|
||||
|
||||
// ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||
log(color.RedString("failed to create socket directory: %w\n", err))
|
||||
log(color.RedString("failed to create socket directory: %s\n", err))
|
||||
return res
|
||||
}
|
||||
|
||||
// delete existing socket file if it exists
|
||||
if _, err := os.Stat(socketPath); err == nil {
|
||||
if err := os.Remove(socketPath); err != nil {
|
||||
log(color.RedString("failed to remove existing socket file: %w\n", err))
|
||||
log(color.RedString("failed to remove existing socket file: %s\n", err))
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log(color.RedString("failed to listen on unix socket %s: %w\n", socketPath, err))
|
||||
log(color.RedString("failed to listen on unix socket %s: %s\n", socketPath, err))
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
@@ -7,70 +7,61 @@ 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"
|
||||
)
|
||||
|
||||
var count = &cli.Command{
|
||||
Name: "count",
|
||||
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
|
||||
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
|
||||
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: []cli.Flag{
|
||||
&PubKeySliceFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "only accept events from these authors",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntSliceFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"k"},
|
||||
Usage: "only accept events with these kind numbers",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tag",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "e",
|
||||
Usage: "shortcut for --tag e=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "p",
|
||||
Usage: "shortcut for --tag p=<value>",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "since",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "only accept events newer than this (unix timestamp)",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&NaturalTimeFlag{
|
||||
Name: "until",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "only accept events older than this (unix timestamp)",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "only accept up to this number of events",
|
||||
Category: CATEGORY_FILTER_ATTRIBUTES,
|
||||
},
|
||||
},
|
||||
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)
|
||||
@@ -84,95 +75,62 @@ var count = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
filter := nostr.Filter{}
|
||||
|
||||
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
|
||||
filter.Authors = authors
|
||||
}
|
||||
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
|
||||
kinds := make([]nostr.Kind, len(kinds64))
|
||||
for i, v := range kinds64 {
|
||||
kinds[i] = nostr.Kind(v)
|
||||
}
|
||||
filter.Kinds = kinds
|
||||
}
|
||||
|
||||
tags := make([][]string, 0, 5)
|
||||
for _, tagFlag := range c.StringSlice("tag") {
|
||||
spl := strings.SplitN(tagFlag, "=", 2)
|
||||
if len(spl) == 2 {
|
||||
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
|
||||
} else {
|
||||
return fmt.Errorf("invalid --tag '%s'", tagFlag)
|
||||
}
|
||||
}
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
tags = append(tags, []string{"e", decodeTagValue(etag)})
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
tags = append(tags, []string{"p", decodeTagValue(ptag)})
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
filter.Tags = make(nostr.TagMap)
|
||||
for _, tag := range tags {
|
||||
if _, ok := filter.Tags[tag[0]]; !ok {
|
||||
filter.Tags[tag[0]] = make([]string, 0, 3)
|
||||
}
|
||||
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
if c.IsSet("since") {
|
||||
filter.Since = getNaturalDate(c, "since")
|
||||
}
|
||||
if c.IsSet("until") {
|
||||
filter.Until = getNaturalDate(c, "until")
|
||||
}
|
||||
|
||||
if limit := c.Int("limit"); limit != 0 {
|
||||
filter.Limit = int(limit)
|
||||
}
|
||||
|
||||
successes := 0
|
||||
if len(relayUrls) > 0 {
|
||||
var hll *hyperloglog.HyperLogLog
|
||||
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
|
||||
hll = hyperloglog.New(offset)
|
||||
}
|
||||
for _, relayUrl := range relayUrls {
|
||||
relay, _ := sys.Pool.EnsureRelay(relayUrl)
|
||||
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-count",
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
|
||||
// go line by line from stdin or run once with input from flags
|
||||
for stdinFilter := range getJsonsOrBlank() {
|
||||
filter := nostr.Filter{}
|
||||
if stdinFilter != "" {
|
||||
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var hasHLLStr string
|
||||
if hll != nil && len(hllRegisters) == 256 {
|
||||
hll.MergeRegisters(hllRegisters)
|
||||
hasHLLStr = " 📋"
|
||||
if err := applyFlagsToFilter(c, &filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
successes := 0
|
||||
if len(relayUrls) > 0 {
|
||||
var hll *hyperloglog.HyperLogLog
|
||||
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
|
||||
hll = hyperloglog.New(offset)
|
||||
}
|
||||
for _, relayUrl := range relayUrls {
|
||||
relay, _ := sys.Pool.EnsureRelay(relayUrl)
|
||||
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-count",
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
|
||||
successes++
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var hasHLLStr string
|
||||
if hll != nil && len(hllRegisters) == 256 {
|
||||
hll.MergeRegisters(hllRegisters)
|
||||
hasHLLStr = " (hll)"
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
|
||||
successes++
|
||||
}
|
||||
if successes == 0 {
|
||||
return fmt.Errorf("all relays have failed")
|
||||
} else if hll != nil {
|
||||
fmt.Fprintf(os.Stderr, "HyperLogLog sum: %d\n", hll.Count())
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
var result string
|
||||
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
|
||||
result = string(j)
|
||||
stdout(result)
|
||||
}
|
||||
if successes == 0 {
|
||||
return fmt.Errorf("all relays have failed")
|
||||
} else if hll != nil {
|
||||
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
|
||||
}
|
||||
} else {
|
||||
// no relays given, will just print the filter
|
||||
var result string
|
||||
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
|
||||
result = string(j)
|
||||
stdout(result)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ var decrypt = &cli.Command{
|
||||
|
||||
res, err := kr.Decrypt(ctx, ciphertext, source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt: %w", err)
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
stdout(res)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,18 @@ example:
|
||||
Hidden: true,
|
||||
},
|
||||
// ~~~
|
||||
&cli.BoolFlag{
|
||||
Name: "force-sign",
|
||||
Usage: "when an event is already signed and not modified it isn't signed again even when a different --sec is given, this option negates that",
|
||||
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",
|
||||
@@ -86,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"},
|
||||
@@ -94,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"},
|
||||
@@ -123,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"},
|
||||
@@ -139,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 {
|
||||
@@ -169,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"`)
|
||||
@@ -212,8 +231,12 @@ example:
|
||||
if found {
|
||||
// tags may also contain extra elements separated with a ";"
|
||||
tagValues := strings.Split(tagValue, ";")
|
||||
val := tagValues[0]
|
||||
if len(tagName) == 1 {
|
||||
val = decodeTagValue(val, rune(tagName[0]))
|
||||
}
|
||||
if len(tagValues) >= 1 {
|
||||
tagValues[0] = decodeTagValue(tagValues[0])
|
||||
tagValues[0] = val
|
||||
}
|
||||
tag = append(tag, tagValues...)
|
||||
}
|
||||
@@ -221,21 +244,45 @@ example:
|
||||
}
|
||||
|
||||
for _, etag := range c.StringSlice("e") {
|
||||
decodedEtag := decodeTagValue(etag)
|
||||
decodedEtag := decodeTagValue(etag, 'e')
|
||||
if tags.FindWithValue("e", decodedEtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"e", decodedEtag})
|
||||
}
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
decodedPtag := decodeTagValue(ptag)
|
||||
decodedPtag := decodeTagValue(ptag, 'p')
|
||||
if tags.FindWithValue("p", decodedPtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"p", decodedPtag})
|
||||
}
|
||||
}
|
||||
for _, dtag := range c.StringSlice("d") {
|
||||
decodedDtag := decodeTagValue(dtag)
|
||||
if tags.FindWithValue("d", decodedDtag) == nil {
|
||||
tags = append(tags, nostr.Tag{"d", decodedDtag})
|
||||
if tags.FindWithValue("d", dtag) == nil {
|
||||
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 {
|
||||
@@ -244,6 +291,9 @@ example:
|
||||
}
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
if authorPubKey != nostr.ZeroPK {
|
||||
evt.PubKey = authorPubKey
|
||||
}
|
||||
|
||||
if c.IsSet("created-at") {
|
||||
evt.CreatedAt = getNaturalDate(c, "created-at")
|
||||
@@ -253,7 +303,7 @@ example:
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
|
||||
if c.IsSet("musig") || c.Bool("force-sign") {
|
||||
mustRehashAndResign = true
|
||||
}
|
||||
|
||||
@@ -268,7 +318,7 @@ example:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
} else if evt.PubKey == nostr.ZeroPK {
|
||||
evt.PubKey, _ = kr.GetPublicKey(ctx)
|
||||
}
|
||||
|
||||
@@ -279,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")
|
||||
@@ -304,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") {
|
||||
@@ -340,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]
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
|
||||
if has {
|
||||
if err != nil {
|
||||
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||
return fmt.Errorf("receiver's decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||
}
|
||||
ciphers = append(ciphers, kr)
|
||||
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
|
||||
@@ -209,9 +209,12 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
for c, potentialCipher := range ciphers {
|
||||
switch c {
|
||||
case 0:
|
||||
log("- trying the receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||
case 1:
|
||||
log("- trying the receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||
if eSec.Public() == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
log("- trying receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||
}
|
||||
|
||||
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
|
||||
@@ -227,7 +230,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
cipher = potentialCipher
|
||||
break
|
||||
}
|
||||
if seal.ID == nostr.ZeroID {
|
||||
if seal.ID == nostr.ZeroID && seal.PubKey == nostr.ZeroPK && seal.CreatedAt == 0 {
|
||||
// if both ciphers failed above we'll reach here
|
||||
return fmt.Errorf("failed to decrypt seal: %w", err)
|
||||
}
|
||||
@@ -236,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)
|
||||
@@ -247,11 +254,15 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
var rumor nostr.Event
|
||||
err = nil
|
||||
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
|
||||
if senderEncryptionPublicKey == nostr.ZeroPK {
|
||||
continue
|
||||
}
|
||||
|
||||
switch s {
|
||||
case 0:
|
||||
log("- trying the sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
case 1:
|
||||
log("- trying the sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||
}
|
||||
|
||||
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
|
||||
@@ -267,10 +278,13 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
|
||||
break
|
||||
}
|
||||
|
||||
if rumor.ID == nostr.ZeroID {
|
||||
if rumor.ID == nostr.ZeroID && rumor.PubKey == nostr.ZeroPK && rumor.CreatedAt == 0 {
|
||||
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())
|
||||
}
|
||||
@@ -321,7 +335,6 @@ func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pub
|
||||
if eSec.Public() != ePub {
|
||||
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
||||
}
|
||||
|
||||
return eSec, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817
|
||||
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.2
|
||||
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.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
|
||||
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817 h1:Zp6rPetvwYFOLD+36RtmWmns2C0CLbtphD3DLu3cxCo=
|
||||
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||
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=
|
||||
|
||||
@@ -2,13 +2,21 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip11"
|
||||
"fiatjaf.com/nostr/nip29"
|
||||
"fiatjaf.com/nostr/nip42"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
@@ -20,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",
|
||||
@@ -33,15 +62,9 @@ var group = &cli.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
group := nip29.Group{}
|
||||
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
|
||||
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
group, err := fetchGroupMetadata(ctx, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
|
||||
@@ -68,6 +91,24 @@ var group = &cli.Command{
|
||||
", "+
|
||||
cond(group.Private, "group content is not accessible to non-members", "group content is public"),
|
||||
)
|
||||
stdout("livekit:",
|
||||
color.HiBlueString("%s", cond(group.LiveKit, "yes", "no"))+
|
||||
", "+
|
||||
cond(group.LiveKit, "group supports live audio/video with livekit", "group has no advertised live audio/video support"),
|
||||
)
|
||||
supportedKinds := "unspecified"
|
||||
if group.SupportedKinds != nil {
|
||||
if len(group.SupportedKinds) == 0 {
|
||||
supportedKinds = "none"
|
||||
} else {
|
||||
kinds := make([]string, 0, len(group.SupportedKinds))
|
||||
for _, kind := range group.SupportedKinds {
|
||||
kinds = append(kinds, strconv.Itoa(int(kind)))
|
||||
}
|
||||
supportedKinds = strings.Join(kinds, ", ")
|
||||
}
|
||||
}
|
||||
stdout("supported-kinds:", color.HiBlueString(supportedKinds))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -234,58 +275,33 @@ var group = &cli.Command{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -329,9 +345,9 @@ var group = &cli.Command{
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "forum",
|
||||
Usage: "read group forum posts",
|
||||
Description: "access group forum functionality.",
|
||||
Name: "talk",
|
||||
Usage: "get livekit connection details",
|
||||
Description: "requests a livekit jwt for this group and prints the livekit server url.",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
@@ -339,25 +355,261 @@ var group = &cli.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{11},
|
||||
Tags: nostr.TagMap{"#h": []string{identifier}},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
title := evt.Tags.Find("title")
|
||||
if title != nil {
|
||||
stdout(colors.bold(title[1]))
|
||||
} else {
|
||||
stdout(colors.bold("<untitled>"))
|
||||
}
|
||||
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||
stdout("by " + evt.PubKey.Hex() + " (" + color.HiBlueString(meta.ShortName()) + ") at " + evt.CreatedAt.Time().Format(time.DateTime))
|
||||
stdout(evt.Content)
|
||||
group, err := fetchGroupMetadata(ctx, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !group.LiveKit {
|
||||
return fmt.Errorf("group doesn't advertise livekit support")
|
||||
}
|
||||
// TODO: see what to do about this
|
||||
|
||||
serverURL, jwt, err := requestLivekitJWT(ctx, c, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stdout("livekit:", color.HiBlueString(serverURL))
|
||||
stdout("jwt:", color.HiBlueString(jwt))
|
||||
stdout("join:", color.HiBlueString(
|
||||
fmt.Sprintf("https://meet.livekit.io/custom?liveKitUrl=%s&token=%s", serverURL, jwt)),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "forum",
|
||||
Usage: "forum topic operations",
|
||||
Description: "when called directly, lists forum topics; with an id prefix, displays that topic with threaded comments.",
|
||||
ArgsUsage: "<relay>'<identifier> [id-prefix]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prefix := strings.TrimSpace(c.Args().Get(1))
|
||||
if prefix == "" {
|
||||
if len(topics) == 0 {
|
||||
log("no forum topics found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
for _, evt := range topics {
|
||||
wg.Go(func() {
|
||||
sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, evt := range topics {
|
||||
id := evt.ID.Hex()
|
||||
date := evt.CreatedAt.Time().Format(time.DateOnly)
|
||||
author := authorPreview(ctx, evt.PubKey)
|
||||
subject := forumSubjectPreview(evt, 72)
|
||||
if subject == "" {
|
||||
subject = "<untitled>"
|
||||
}
|
||||
stdout(color.CyanString(id[:6]), color.HiBlackString(date), color.HiBlueString(author), color.HiWhiteString(subject))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
evt, err := findEventByPrefix(topics, prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return showThreadWithComments(ctx, []string{relay}, evt, "", nostr.TagMap{"h": []string{identifier}})
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "create",
|
||||
Usage: "edit and send a forum topic event (kind 11)",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupName := groupMeta.Name
|
||||
if groupName == "" {
|
||||
groupName = identifier
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to gather keyer: %w", err)
|
||||
}
|
||||
|
||||
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current identity: %w", err)
|
||||
}
|
||||
|
||||
content, err := editWithDefaultEditor(
|
||||
"nak-group-forum/NOTES_EDITMSG",
|
||||
strings.TrimSpace(fmt.Sprintf(`# creating as '%s' ('%s')
|
||||
# creating forum topic in group '%s' ('%s''%s')
|
||||
# the first line will be used as the topic title
|
||||
topic title here
|
||||
|
||||
# the remaining lines will be the body
|
||||
write your forum post
|
||||
|
||||
# lines starting with '#' are ignored
|
||||
`, selfName, selfNpub, groupName, relay, identifier)),
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title, body, err := parseForumCreateContent(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 11,
|
||||
Tags: nostr.Tags{
|
||||
nostr.Tag{"h", identifier},
|
||||
nostr.Tag{"title", title},
|
||||
},
|
||||
Content: body,
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("failed to sign forum topic event: %w", err)
|
||||
}
|
||||
|
||||
r, err := sys.Pool.EnsureRelay(relay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.Publish(ctx, evt)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "comment",
|
||||
Usage: "comment on a forum topic with a NIP-22 comment event",
|
||||
ArgsUsage: "<relay>'<identifier> <id-prefix>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prefix := strings.TrimSpace(c.Args().Get(1))
|
||||
if prefix == "" {
|
||||
return fmt.Errorf("missing forum topic id prefix")
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to gather keyer: %w", err)
|
||||
}
|
||||
|
||||
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current identity: %w", err)
|
||||
}
|
||||
|
||||
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
topic, err := findEventByPrefix(topics, prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupName := groupMeta.Name
|
||||
if groupName == "" {
|
||||
groupName = identifier
|
||||
}
|
||||
|
||||
subject := forumSubjectPreview(topic, 72)
|
||||
if subject == "" {
|
||||
subject = "<untitled>"
|
||||
}
|
||||
pm := sys.FetchProfileMetadata(ctx, topic.PubKey)
|
||||
headerLines := []string{
|
||||
fmt.Sprintf("commenting as '%s' ('%s')", selfName, selfNpub),
|
||||
fmt.Sprintf("commenting on forum topic '%s' '%s' by '%s' ('%s') in group '%s' ('%s''%s')", topic.ID.Hex()[:6], subject, pm.ShortName(), pm.NpubShort(), groupName, relay, identifier),
|
||||
}
|
||||
|
||||
comments, err := fetchThreadComments(ctx, []string{relay}, topic.ID, nostr.TagMap{"h": []string{identifier}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
edited, err := editWithDefaultEditor(
|
||||
"nak-group-forum-reply/NOTES_EDITMSG",
|
||||
threadReplyEditorTemplate(ctx, headerLines, topic, comments),
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, parentEvt, err := parseThreadReplyContent(topic, comments, edited)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rootRelay := relay
|
||||
if topic.Relay.URL != "" {
|
||||
rootRelay = topic.Relay.URL
|
||||
}
|
||||
parentRelay := rootRelay
|
||||
if parentEvt.Relay.URL != "" {
|
||||
parentRelay = parentEvt.Relay.URL
|
||||
}
|
||||
|
||||
evt := nostr.Event{
|
||||
CreatedAt: nostr.Now(),
|
||||
Kind: 1111,
|
||||
Tags: nostr.Tags{
|
||||
nostr.Tag{"E", topic.ID.Hex(), rootRelay},
|
||||
nostr.Tag{"e", parentEvt.ID.Hex(), parentRelay},
|
||||
nostr.Tag{"P", topic.PubKey.Hex()},
|
||||
nostr.Tag{"p", parentEvt.PubKey.Hex()},
|
||||
nostr.Tag{"h", identifier},
|
||||
nostr.Tag{"K", strconv.Itoa(int(topic.Kind))},
|
||||
},
|
||||
Content: content,
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("failed to sign forum comment event: %w", err)
|
||||
}
|
||||
|
||||
r, err := sys.Pool.EnsureRelay(relay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.Publish(ctx, evt)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "put-user",
|
||||
Usage: "add a user to the group with optional roles",
|
||||
@@ -372,7 +624,7 @@ var group = &cli.Command{
|
||||
},
|
||||
},
|
||||
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")...)
|
||||
@@ -392,13 +644,23 @@ var group = &cli.Command{
|
||||
},
|
||||
},
|
||||
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",
|
||||
@@ -437,37 +699,114 @@ var group = &cli.Command{
|
||||
&cli.BoolFlag{
|
||||
Name: "public",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "livekit",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-livekit",
|
||||
},
|
||||
&cli.IntSliceFlag{
|
||||
Name: "kind",
|
||||
Aliases: []string{"supported-kinds"},
|
||||
Usage: "list of event kind numbers supported by this group",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "all-kinds",
|
||||
Usage: "specify this to delete the supported_kinds property, meaning everything will be supported",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
|
||||
if name := c.String("name"); name != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"name", name})
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool("livekit") || c.Bool("no-livekit") {
|
||||
if err := checkRelayLivekitMetadataSupport(ctx, relay); err != nil {
|
||||
return err
|
||||
}
|
||||
if picture := c.String("picture"); picture != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"picture", picture})
|
||||
}
|
||||
|
||||
group, err := fetchGroupMetadata(ctx, relay, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group.Name == "" {
|
||||
group.Name = identifier
|
||||
}
|
||||
|
||||
if name := c.String("name"); name != "" {
|
||||
group.Name = name
|
||||
}
|
||||
if picture := c.String("picture"); picture != "" {
|
||||
group.Picture = picture
|
||||
}
|
||||
if about := c.String("about"); about != "" {
|
||||
group.About = about
|
||||
}
|
||||
if c.Bool("restricted") {
|
||||
group.Restricted = true
|
||||
} else if c.Bool("unrestricted") {
|
||||
group.Restricted = false
|
||||
}
|
||||
if c.Bool("closed") {
|
||||
group.Closed = true
|
||||
} else if c.Bool("open") {
|
||||
group.Closed = false
|
||||
}
|
||||
if c.Bool("hidden") {
|
||||
group.Hidden = true
|
||||
} else if c.Bool("visible") {
|
||||
group.Hidden = false
|
||||
}
|
||||
if c.Bool("private") {
|
||||
group.Private = true
|
||||
} else if c.Bool("public") {
|
||||
group.Private = false
|
||||
}
|
||||
if c.Bool("livekit") {
|
||||
group.LiveKit = true
|
||||
} else if c.Bool("no-livekit") {
|
||||
group.LiveKit = false
|
||||
}
|
||||
if supportedKinds := c.IntSlice("kind"); len(supportedKinds) > 0 {
|
||||
kinds := make([]nostr.Kind, 0, len(supportedKinds))
|
||||
for _, kind := range supportedKinds {
|
||||
kinds = append(kinds, nostr.Kind(kind))
|
||||
}
|
||||
if about := c.String("about"); about != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"about", about})
|
||||
}
|
||||
if c.Bool("restricted") {
|
||||
group.SupportedKinds = kinds
|
||||
} else if c.Bool("all-kinds") {
|
||||
group.SupportedKinds = nil
|
||||
}
|
||||
|
||||
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 if c.Bool("unrestricted") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
|
||||
}
|
||||
if c.Bool("closed") {
|
||||
if group.Closed {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
|
||||
} else if c.Bool("open") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
|
||||
}
|
||||
if c.Bool("hidden") {
|
||||
if group.Hidden {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
|
||||
} else if c.Bool("visible") {
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
|
||||
}
|
||||
if c.Bool("private") {
|
||||
if group.Private {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
|
||||
} else if c.Bool("public") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
|
||||
}
|
||||
if group.LiveKit {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"livekit"})
|
||||
}
|
||||
if group.SupportedKinds != nil {
|
||||
tag := make(nostr.Tag, 1, 1+len(group.SupportedKinds))
|
||||
tag[0] = "supported_kinds"
|
||||
for _, kind := range group.SupportedKinds {
|
||||
tag = append(tag, strconv.Itoa(int(kind)))
|
||||
}
|
||||
evt.Tags = append(evt.Tags, tag)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -484,7 +823,7 @@ var group = &cli.Command{
|
||||
},
|
||||
},
|
||||
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
|
||||
@@ -496,7 +835,7 @@ var group = &cli.Command{
|
||||
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
|
||||
})
|
||||
},
|
||||
@@ -512,7 +851,7 @@ var group = &cli.Command{
|
||||
},
|
||||
},
|
||||
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
|
||||
})
|
||||
@@ -521,7 +860,7 @@ var group = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -584,3 +923,180 @@ func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err
|
||||
|
||||
return strings.TrimSuffix(parts[0], "/"), parts[1], nil
|
||||
}
|
||||
|
||||
func fetchGroupMetadata(ctx context.Context, relay string, identifier string) (nip29.Group, error) {
|
||||
group := nip29.Group{}
|
||||
|
||||
filter := nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
|
||||
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
if info, err := nip11.Fetch(ctx, relay); err == nil {
|
||||
if info.Self != nil {
|
||||
filter.Authors = append(filter.Authors, *info.Self)
|
||||
} else if info.PubKey != nil {
|
||||
filter.Authors = append(filter.Authors, *info.PubKey)
|
||||
}
|
||||
}
|
||||
|
||||
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
|
||||
return group, err
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
return group, fmt.Errorf("couldn't fetch group metadata")
|
||||
}
|
||||
|
||||
func fetchGroupForumTopics(ctx context.Context, relay string, identifier string) ([]nostr.RelayEvent, error) {
|
||||
topics := make([]nostr.RelayEvent, 0, 30)
|
||||
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{11},
|
||||
Tags: nostr.TagMap{"h": []string{identifier}},
|
||||
Limit: 500,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
topics = append(topics, ie)
|
||||
}
|
||||
|
||||
slices.SortFunc(topics, nostr.CompareRelayEvent)
|
||||
return topics, nil
|
||||
}
|
||||
|
||||
func forumSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
|
||||
if tag := evt.Tags.Find("title"); len(tag) >= 2 {
|
||||
subject := strings.TrimSpace(tag[1])
|
||||
if subject != "" {
|
||||
return clampWithEllipsis(subject, maxChars)
|
||||
}
|
||||
}
|
||||
|
||||
if tag := evt.Tags.Find("subject"); len(tag) >= 2 {
|
||||
subject := strings.TrimSpace(tag[1])
|
||||
if subject != "" {
|
||||
return clampWithEllipsis(subject, maxChars)
|
||||
}
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(evt.Content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
return clampWithEllipsis(line, maxChars)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseForumCreateContent(content string) (title string, body string, err error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
var bodyb strings.Builder
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = line
|
||||
continue
|
||||
}
|
||||
|
||||
bodyb.WriteString(line)
|
||||
bodyb.WriteByte('\n')
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
return "", "", fmt.Errorf("topic title cannot be empty")
|
||||
}
|
||||
|
||||
body = strings.TrimSpace(bodyb.String())
|
||||
return title, body, nil
|
||||
}
|
||||
|
||||
func checkRelayLivekitMetadataSupport(ctx context.Context, relay string) error {
|
||||
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create livekit support request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check relay livekit support: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("relay doesn't advertise livekit support at %s (expected 204, got %d)", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestLivekitJWT(ctx context.Context, c *cli.Command, relay string, identifier string) (serverURL string, jwt string, err error) {
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit/" + identifier
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create livekit token request: %w", err)
|
||||
}
|
||||
|
||||
tokenEvent := nostr.Event{
|
||||
Kind: 27235,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"u", url},
|
||||
{"method", "GET"},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
|
||||
return "", "", fmt.Errorf("failed to sign livekit auth token: %w", err)
|
||||
}
|
||||
|
||||
evtj, _ := stdjson.Marshal(tokenEvent)
|
||||
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("livekit token request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed reading livekit token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if msg != "" {
|
||||
return "", "", fmt.Errorf("livekit token request failed with status %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return "", "", fmt.Errorf("livekit token request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
response := struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
ParticipantToken string `json:"participant_token"`
|
||||
}{}
|
||||
if err := stdjson.Unmarshal(body, &response); err != nil {
|
||||
return "", "", fmt.Errorf("invalid livekit token response: %w", err)
|
||||
}
|
||||
|
||||
serverURL = response.ServerURL
|
||||
jwt = response.ParticipantToken
|
||||
|
||||
if serverURL == "" || jwt == "" {
|
||||
return "", "", fmt.Errorf("livekit token response missing url or jwt")
|
||||
}
|
||||
|
||||
return serverURL, jwt, nil
|
||||
}
|
||||
|
||||
+110
-35
@@ -7,15 +7,16 @@ import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip05"
|
||||
@@ -26,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"
|
||||
)
|
||||
@@ -61,6 +62,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
|
||||
var finalJsonErr error
|
||||
return func(yield func(string) bool) {
|
||||
stopped := false
|
||||
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||
// we're look for an event, but it may be in multiple lines, so if json parsing fails
|
||||
// we'll try the next line until we're successful
|
||||
@@ -75,6 +77,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
finalJsonErr = nil
|
||||
|
||||
if !yield(stdinEvent) {
|
||||
stopped = true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -82,8 +85,14 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
return true
|
||||
})
|
||||
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
yield("{}")
|
||||
if !yield("{}") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if finalJsonErr != nil {
|
||||
@@ -94,15 +103,23 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
|
||||
func getStdinLinesOrBlank() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
stopped := false
|
||||
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
|
||||
if !yield(stdinLine) {
|
||||
stopped = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if !hasStdin {
|
||||
yield("")
|
||||
if !yield("") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,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() {
|
||||
@@ -230,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)
|
||||
@@ -415,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()
|
||||
@@ -472,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)
|
||||
@@ -526,8 +523,23 @@ func parseEventID(value string) (nostr.ID, error) {
|
||||
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
|
||||
}
|
||||
|
||||
func decodeTagValue(value string) string {
|
||||
if strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1") || strings.HasPrefix(value, "nprofile1") || strings.HasPrefix(value, "naddr1") {
|
||||
func decodeTagValue(value string, letter rune) string {
|
||||
letter = unicode.ToLower(letter)
|
||||
|
||||
if letter == 'p' {
|
||||
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.Hex()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (letter == 'p' && (strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nprofile1"))) ||
|
||||
((letter == 'a' || letter == 'q') && strings.HasPrefix(value, "naddr1")) ||
|
||||
((letter == 'e' || letter == 'q') && (strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1"))) {
|
||||
if ptr, err := nip19.ToPointer(value); err == nil {
|
||||
return ptr.AsTagReference()
|
||||
}
|
||||
@@ -535,6 +547,69 @@ func decodeTagValue(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) {
|
||||
fullpath := filepath.Join(os.TempDir(), filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
if wipe {
|
||||
if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("failed to remove temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tmp.WriteString(initialContent); err != nil {
|
||||
tmp.Close()
|
||||
return "", fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
editor := strings.TrimSpace(os.Getenv("VISUAL"))
|
||||
if editor == "" {
|
||||
editor = strings.TrimSpace(os.Getenv("EDITOR"))
|
||||
}
|
||||
if editor == "" {
|
||||
editor = "edit"
|
||||
}
|
||||
|
||||
parts := strings.Fields(editor)
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("failed to parse editor command '%s'", editor)
|
||||
}
|
||||
|
||||
args := append(parts[1:], tmp.Name())
|
||||
cmd := exec.Command(parts[0], args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("editor command failed: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tmp.Name())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read edited temp file: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func clampWithEllipsis(s string, size int) string {
|
||||
if len(s) <= size {
|
||||
return s
|
||||
}
|
||||
return s[0:size-1] + "…"
|
||||
}
|
||||
|
||||
var colors = struct {
|
||||
reset func(...any) (int, error)
|
||||
italic func(...any) string
|
||||
|
||||
+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,11 +49,14 @@ var app = &cli.Command{
|
||||
curl,
|
||||
fsCmd,
|
||||
publish,
|
||||
nsite,
|
||||
git,
|
||||
group,
|
||||
nip,
|
||||
syncCmd,
|
||||
spell,
|
||||
profile,
|
||||
validateCmd,
|
||||
},
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
@@ -102,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
|
||||
},
|
||||
@@ -119,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() {
|
||||
|
||||
@@ -158,29 +158,40 @@ var mcpServer = &cli.Command{
|
||||
name := required[string](r, "name")
|
||||
limit, _ := optional[float64](r, "limit")
|
||||
|
||||
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
|
||||
if limit > 0 {
|
||||
filter.Limit = int(limit)
|
||||
}
|
||||
|
||||
res := strings.Builder{}
|
||||
res.Grow(500)
|
||||
res.WriteString("search results: ")
|
||||
l := 0
|
||||
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine"}, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-search",
|
||||
}) {
|
||||
l++
|
||||
pm, _ := sdk.ParseMetadata(result.Event)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
|
||||
if l >= int(limit) {
|
||||
break
|
||||
// check if input is already a valid pubkey
|
||||
if pubkey, err := nostr.PubKeyFromHex(name); err == nil {
|
||||
pm := sys.FetchProfileMetadata(ctx, pubkey)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult 1\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
} else {
|
||||
// otherwise try to search
|
||||
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
|
||||
if limit > 0 {
|
||||
filter.Limit = int(limit)
|
||||
}
|
||||
|
||||
l := 0
|
||||
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine", "search.nos.social"}, filter, nostr.SubscriptionOptions{
|
||||
Label: "nak-mcp-search",
|
||||
}) {
|
||||
l++
|
||||
pm, _ := sdk.ParseMetadata(result.Event)
|
||||
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
|
||||
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
|
||||
|
||||
if l >= int(limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if l == 0 {
|
||||
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
|
||||
}
|
||||
}
|
||||
if l == 0 {
|
||||
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(res.String()), nil
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var profile = &cli.Command{
|
||||
Name: "profile",
|
||||
Usage: "displays profile information for a given pubkey",
|
||||
Description: `fetches and displays profile metadata, relays, and contact count for a given pubkey.
|
||||
|
||||
example usage:
|
||||
nak profile npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps
|
||||
nak profile user@example.com`,
|
||||
ArgsUsage: "[pubkey]",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
for pubkeyInput := range getStdinLinesOrArguments(c.Args()) {
|
||||
pk, err := parsePubKey(pubkeyInput)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err)
|
||||
continue
|
||||
}
|
||||
|
||||
pm := sys.FetchProfileMetadata(ctx, pk)
|
||||
|
||||
npub := nip19.EncodeNpub(pk)
|
||||
stdout(colors.bold("pubkey (hex):"), pk.Hex())
|
||||
stdout(colors.bold("npub:"), color.HiCyanString(npub))
|
||||
|
||||
relayList := sys.FetchRelayList(ctx, pk)
|
||||
writeRelays := make([]string, 0, 3)
|
||||
for _, rl := range relayList.Items {
|
||||
if rl.Outbox {
|
||||
writeRelays = append(writeRelays, rl.URL)
|
||||
if len(writeRelays) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(writeRelays) > 0 {
|
||||
nprofile := nip19.EncodeNprofile(pk, writeRelays)
|
||||
stdout(colors.bold("profile uri:"), color.HiCyanString("nostr:"+nprofile))
|
||||
}
|
||||
|
||||
if pm.Name != "" {
|
||||
stdout(colors.bold("name:"), color.HiBlueString(pm.Name))
|
||||
}
|
||||
if pm.DisplayName != "" {
|
||||
stdout(colors.bold("display_name:"), color.HiBlueString(pm.DisplayName))
|
||||
}
|
||||
if pm.About != "" {
|
||||
stdout(colors.bold("about:"), color.HiBlueString(pm.About))
|
||||
}
|
||||
if pm.Picture != "" {
|
||||
stdout(colors.bold("picture:"), color.HiBlueString(pm.Picture))
|
||||
}
|
||||
if pm.Banner != "" {
|
||||
stdout(colors.bold("banner:"), color.HiBlueString(pm.Banner))
|
||||
}
|
||||
if pm.Website != "" {
|
||||
stdout(colors.bold("website:"), color.HiBlueString(pm.Website))
|
||||
}
|
||||
if pm.NIP05 != "" {
|
||||
isValid := pm.NIP05Valid(ctx)
|
||||
if isValid {
|
||||
stdout(colors.bold("nip05:"), color.HiGreenString(pm.NIP05), color.HiGreenString("(verified)"))
|
||||
} else {
|
||||
stdout(colors.bold("nip05:"), color.HiRedString(pm.NIP05), color.HiRedString("(not verified)"))
|
||||
}
|
||||
}
|
||||
if pm.LUD16 != "" {
|
||||
stdout(colors.bold("lud16:"), color.HiBlueString(pm.LUD16))
|
||||
}
|
||||
|
||||
if len(relayList.Items) > 0 {
|
||||
stdout(colors.bold("relays:"))
|
||||
for _, relay := range relayList.Items {
|
||||
access := ""
|
||||
if relay.Inbox && relay.Outbox {
|
||||
access = "read/write"
|
||||
} else if relay.Inbox {
|
||||
access = "read"
|
||||
} else if relay.Outbox {
|
||||
access = "write"
|
||||
}
|
||||
stdout(" ", color.HiBlueString(relay.URL), color.HiCyanString("(%s)", access))
|
||||
}
|
||||
}
|
||||
|
||||
followList := sys.FetchFollowList(ctx, pk)
|
||||
contactCount := len(followList.Items)
|
||||
stdout(colors.bold("follows:"), color.HiCyanString("%d", contactCount))
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
+8
-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)
|
||||
@@ -109,6 +111,7 @@ example:
|
||||
|
||||
if replyEvent.Kind != 1 {
|
||||
evt.Kind = 1111
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
|
||||
}
|
||||
|
||||
// add reply tags
|
||||
@@ -151,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))
|
||||
@@ -367,25 +454,44 @@ func performReq(
|
||||
readevents:
|
||||
for {
|
||||
select {
|
||||
case ie, ok := <-results:
|
||||
if !ok {
|
||||
case ie, stillOpen := <-results:
|
||||
if !stillOpen {
|
||||
break readevents
|
||||
}
|
||||
stdout(ie.Event)
|
||||
case closed := <-closeds:
|
||||
if closed.HandledAuth {
|
||||
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
|
||||
var out string
|
||||
if jq == nil {
|
||||
out = ie.Event.String()
|
||||
} else {
|
||||
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
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 {
|
||||
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
} else {
|
||||
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break readevents
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var reqFilterFlags = []cli.Flag{
|
||||
&PubKeySliceFlag{
|
||||
&PubKeyOrAddressFlag{
|
||||
Name: "author",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "only accept events from these authors",
|
||||
@@ -424,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"},
|
||||
@@ -449,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...)
|
||||
}
|
||||
@@ -462,23 +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 {
|
||||
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
|
||||
val := spl[1]
|
||||
if len(spl[0]) == 1 {
|
||||
val = decodeTagValue(val, rune(spl[0][0]))
|
||||
}
|
||||
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)})
|
||||
tags = append(tags, flagTag{"e", decodeTagValue(etag, 'e')})
|
||||
}
|
||||
for _, ptag := range c.StringSlice("p") {
|
||||
tags = append(tags, []string{"p", decodeTagValue(ptag)})
|
||||
tags = append(tags, flagTag{"p", decodeTagValue(ptag, 'p')})
|
||||
}
|
||||
for _, dtag := range c.StringSlice("d") {
|
||||
tags = append(tags, []string{"d", decodeTagValue(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 {
|
||||
@@ -486,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") {
|
||||
@@ -510,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)
|
||||
|
||||
@@ -82,19 +82,13 @@ var spell = &cli.Command{
|
||||
|
||||
displayName := entry.Name
|
||||
if displayName == "" {
|
||||
displayName = entry.Content
|
||||
if len(displayName) > 28 {
|
||||
displayName = displayName[:27] + "…"
|
||||
}
|
||||
displayName = clampWithEllipsis(entry.Content, 28)
|
||||
}
|
||||
if displayName != "" {
|
||||
displayName = color.HiMagentaString(displayName) + ": "
|
||||
}
|
||||
|
||||
desc := entry.Content
|
||||
if len(desc) > 50 {
|
||||
desc = desc[0:49] + "…"
|
||||
}
|
||||
desc := clampWithEllipsis(entry.Content, 50)
|
||||
|
||||
lastUsed := entry.LastUsed.Format("2006-01-02 15:04")
|
||||
stdout(fmt.Sprintf(" %s %s%s - %s",
|
||||
@@ -254,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
|
||||
}
|
||||
@@ -448,20 +444,13 @@ func logSpellDetails(spell nostr.Event) {
|
||||
nameTag := spell.Tags.Find("name")
|
||||
name := ""
|
||||
if nameTag != nil {
|
||||
name = nameTag[1]
|
||||
if len(name) > 28 {
|
||||
name = name[:27] + "…"
|
||||
}
|
||||
name = clampWithEllipsis(nameTag[1], 28)
|
||||
}
|
||||
if name != "" {
|
||||
name = ": " + color.HiMagentaString(name)
|
||||
}
|
||||
|
||||
desc := spell.Content
|
||||
if len(desc) > 50 {
|
||||
desc = desc[0:49] + "…"
|
||||
}
|
||||
|
||||
desc := clampWithEllipsis(spell.Content, 50)
|
||||
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
|
||||
identifier := "spell" + idStr[len(idStr)-7:]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip22"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
func fetchThreadComments(ctx context.Context, relays []string, discussionID nostr.ID, extraTags nostr.TagMap) ([]nostr.RelayEvent, error) {
|
||||
filterTags := nostr.TagMap{
|
||||
"E": []string{discussionID.Hex()},
|
||||
}
|
||||
for key, values := range extraTags {
|
||||
filterTags[key] = values
|
||||
}
|
||||
|
||||
comments := make([]nostr.RelayEvent, 0, 15)
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{1111},
|
||||
Tags: filterTags,
|
||||
Limit: 500,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-thread"}) {
|
||||
comments = append(comments, ie)
|
||||
}
|
||||
|
||||
slices.SortFunc(comments, nostr.CompareRelayEvent)
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
func showThreadWithComments(
|
||||
ctx context.Context,
|
||||
relays []string,
|
||||
evt nostr.RelayEvent,
|
||||
status string,
|
||||
extraTags nostr.TagMap,
|
||||
) error {
|
||||
comments, err := fetchThreadComments(ctx, relays, evt.ID, extraTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printThreadMetadata(ctx, os.Stdout, evt, status, true)
|
||||
stdout("")
|
||||
stdout(evt.Content)
|
||||
|
||||
if len(comments) > 0 {
|
||||
stdout("")
|
||||
stdout(color.CyanString("comments:"))
|
||||
printThreadedComments(ctx, os.Stdout, comments, evt.ID, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printThreadedComments(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
comments []nostr.RelayEvent,
|
||||
discussionID nostr.ID,
|
||||
withColor bool,
|
||||
) {
|
||||
byID := make(map[nostr.ID]struct{}, len(comments)+1)
|
||||
byID[discussionID] = struct{}{}
|
||||
for _, c := range comments {
|
||||
byID[c.ID] = struct{}{}
|
||||
}
|
||||
|
||||
// preload metadata from everybody
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
|
||||
for _, c := range comments {
|
||||
wg.Go(func() {
|
||||
sys.FetchProfileMetadata(ctx, c.PubKey)
|
||||
})
|
||||
|
||||
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := byID[parent.ID]; ok {
|
||||
children[parent.ID] = append(children[parent.ID], c)
|
||||
}
|
||||
}
|
||||
|
||||
for parent := range children {
|
||||
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var render func(parent nostr.ID, depth int)
|
||||
render = func(parent nostr.ID, depth int) {
|
||||
for _, c := range children[parent] {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
author := authorPreview(ctx, c.PubKey)
|
||||
created := c.CreatedAt.Time().Format(time.DateTime)
|
||||
|
||||
if withColor {
|
||||
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
|
||||
} else {
|
||||
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(c.Content, "\n") {
|
||||
fmt.Fprintln(w, indent+" "+line)
|
||||
}
|
||||
fmt.Fprintln(w, indent+"")
|
||||
|
||||
render(c.ID, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
render(discussionID, 0)
|
||||
}
|
||||
|
||||
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
|
||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||
if prefix == "" {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
|
||||
}
|
||||
|
||||
matchCount := 0
|
||||
matched := nostr.RelayEvent{}
|
||||
for _, evt := range events {
|
||||
if strings.HasPrefix(evt.ID.Hex(), prefix) {
|
||||
matched = evt
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
|
||||
if matchCount == 0 {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func printThreadMetadata(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
evt nostr.RelayEvent,
|
||||
status string,
|
||||
withColors bool,
|
||||
) {
|
||||
label := func(s string) string { return s }
|
||||
value := func(s string) string { return s }
|
||||
statusValue := func(s string) string { return s }
|
||||
if withColors {
|
||||
label = func(s string) string { return color.CyanString(s) }
|
||||
value = func(s string) string { return color.HiWhiteString(s) }
|
||||
statusValue = colorizeGitStatus
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
|
||||
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
|
||||
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
|
||||
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
|
||||
if status != "" {
|
||||
fmt.Fprintln(w, label("status:"), statusValue(status))
|
||||
}
|
||||
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
|
||||
fmt.Fprintln(w, label("subject:"), value(subject[1]))
|
||||
fmt.Fprintln(w, "")
|
||||
} else if title := evt.Tags.Find("title"); title != nil && len(title) >= 2 {
|
||||
fmt.Fprintln(w, label("title:"), value(title[1]))
|
||||
fmt.Fprintln(w, "")
|
||||
}
|
||||
}
|
||||
|
||||
func parseThreadReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
|
||||
currentParent := discussion
|
||||
selectedParent := nostr.ZeroID
|
||||
inComments := false
|
||||
|
||||
replyb := strings.Builder{}
|
||||
for _, line := range strings.Split(edited, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
|
||||
inComments = false
|
||||
currentParent = discussion
|
||||
continue
|
||||
}
|
||||
|
||||
if replyb.Len() == 0 && line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#>") {
|
||||
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
|
||||
if quoted == "comments:" {
|
||||
inComments = true
|
||||
currentParent = discussion
|
||||
continue
|
||||
}
|
||||
|
||||
// keep track of which comment the reply body shows up below of
|
||||
// so we can assign it as a reply to that specifically
|
||||
fields := strings.Fields(quoted)
|
||||
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
|
||||
currId := fields[0][1 : len(fields[0])-1]
|
||||
for _, comment := range comments {
|
||||
if strings.HasPrefix(comment.ID.Hex(), currId) {
|
||||
currentParent = comment
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// if we reach here this is a line for the reply input from the user
|
||||
replyb.WriteString(line)
|
||||
replyb.WriteByte('\n')
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
|
||||
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
|
||||
}
|
||||
|
||||
selectedParent = currentParent.ID
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(replyb.String())
|
||||
if content == "" {
|
||||
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
|
||||
}
|
||||
|
||||
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
|
||||
return content, discussion, nil
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.ID == selectedParent {
|
||||
return content, comment, nil
|
||||
}
|
||||
}
|
||||
|
||||
panic("selected reply parent not found (this never happens)")
|
||||
}
|
||||
|
||||
func threadReplyEditorTemplate(ctx context.Context, headerLines []string, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
|
||||
lines := make([]string, 0, len(headerLines)+3)
|
||||
for _, line := range headerLines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "# "+strings.TrimSpace(line))
|
||||
}
|
||||
lines = append(lines,
|
||||
"# write your reply here.",
|
||||
"# lines starting with '#' are ignored.",
|
||||
"",
|
||||
)
|
||||
|
||||
appender := &lineAppender{lines, "#> "}
|
||||
|
||||
printThreadMetadata(ctx, appender, discussion, "", false)
|
||||
|
||||
for _, line := range strings.Split(discussion.Content, "\n") {
|
||||
appender.lines = append(appender.lines, "#> "+line)
|
||||
}
|
||||
|
||||
if len(comments) > 0 {
|
||||
appender.lines = append(appender.lines, "#> ", "#> comments:")
|
||||
printThreadedComments(ctx, appender, comments, discussion.ID, false)
|
||||
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
|
||||
}
|
||||
|
||||
return strings.Join(appender.lines, "\n")
|
||||
}
|
||||
|
||||
func keyerIdentity(ctx context.Context, kr nostr.Keyer) (nostr.PubKey, string, string, error) {
|
||||
pk, err := kr.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return nostr.ZeroPK, "", "", err
|
||||
}
|
||||
|
||||
meta := sys.FetchProfileMetadata(ctx, pk)
|
||||
return pk, meta.ShortName(), meta.NpubShort(), nil
|
||||
}
|
||||
|
||||
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
|
||||
meta := sys.FetchProfileMetadata(ctx, pubkey)
|
||||
if meta.Name != "" {
|
||||
return meta.ShortName() + " (" + meta.NpubShort() + ")"
|
||||
}
|
||||
return meta.NpubShort()
|
||||
}
|
||||
|
||||
type lineAppender struct {
|
||||
lines []string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (l *lineAppender) Write(b []byte) (int, error) {
|
||||
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
|
||||
line = strings.TrimRight(line, " ")
|
||||
l.lines = append(l.lines, l.prefix+line)
|
||||
}
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
+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)
|
||||
|
||||
@@ -28,7 +28,7 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
|
||||
relays := sys.FetchOutboxRelays(ctx, pk, 3)
|
||||
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
|
||||
if w == nil {
|
||||
return nil, nil, fmt.Errorf("error loading walle")
|
||||
return nil, nil, fmt.Errorf("error loading wallet")
|
||||
}
|
||||
|
||||
w.Processed = func(evt nostr.Event, err error) {
|
||||
@@ -370,6 +370,10 @@ var wallet = &cli.Command{
|
||||
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "target",
|
||||
Usage: "npub, nprofile, nevent or hex pubkey",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mint",
|
||||
Usage: "send from a specific mint",
|
||||
@@ -380,9 +384,8 @@ var wallet = &cli.Command{
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
|
||||
if c.Args().Len() < 1 {
|
||||
return fmt.Errorf("must be called as `nak wallet nutzap <amount> --target <target>...")
|
||||
}
|
||||
|
||||
w, closew, err := prepareWallet(ctx, c)
|
||||
|
||||
Reference in New Issue
Block a user