Compare commits

..

51 Commits

Author SHA1 Message Date
fiatjaf
f450e735b6 sticky version. 2025-05-20 23:45:54 -03:00
franzaps
1304a65179 Update zapstore.yaml 2025-05-20 23:36:26 -03:00
fiatjaf
aa89093d57 accept bunker URIs in $NOSTR_SECRET_KEY, simplify.
fixes https://github.com/fiatjaf/nak/issues/66
2025-05-20 23:32:04 -03:00
fiatjaf
4387595437 serve: display number of connections. 2025-05-17 21:42:45 -03:00
fiatjaf
4eb5e929d4 blossom: upload from stdin. 2025-05-14 23:43:11 -03:00
fiatjaf
150625ee74 remove debug.PrintStack() 2025-05-12 09:20:27 -03:00
fiatjaf
fc255b5a9a optimized clamped error message for status code failures. 2025-05-11 12:15:50 -03:00
fiatjaf
5bcf2da794 adapt serve to variable max eventstores. 2025-05-11 12:09:56 -03:00
fiatjaf
aadcc73906 adapt to since and until not being pointers. 2025-05-08 09:59:03 -03:00
fiatjaf
f799c65779 add nak publish to README. 2025-05-07 07:59:43 -03:00
fiatjaf
c3822225b4 small tweaks to readme examples. 2025-05-06 11:50:29 -03:00
fiatjaf
67e291e80d nak publish 2025-05-06 00:56:49 -03:00
fiatjaf
83195d9a00 fs: use sdk/PrepareNoteEvent() when publishing. 2025-05-06 00:05:50 -03:00
fiatjaf
f9033f778d adapt wallet to upstream changes. 2025-05-05 16:57:05 -03:00
fiatjaf
9055f98f66 use color.Output and color.Error instead of os.Stdout and os.Stderr in some places. 2025-05-03 21:45:28 -03:00
fiatjaf
02f22a8c2f nak event --confirm 2025-05-03 21:44:59 -03:00
Alex Gleason
f98bd7483f allow --prompt-sec to be used with pipes 2025-05-03 11:58:17 -03:00
fiatjaf
3005c62566 blossom method name update. 2025-05-03 07:22:08 -03:00
fiatjaf
e91a454fc0 nak encode that takes json from stdin. 2025-04-25 13:30:32 -03:00
fiatjaf
148f6e8bcb remove cruft and comments from flags.go 2025-04-25 12:45:38 -03:00
fiatjaf
024111a8be fix bunker client key variable. 2025-04-24 13:22:44 -03:00
fiatjaf
8fba611ad0 mcp: make search return multiple users and also their name and description. 2025-04-22 15:32:48 -03:00
fiatjaf
4d12550d74 bunker: cosmetic fixes. 2025-04-22 08:38:00 -03:00
fiatjaf
5d44600f17 test and fixes. 2025-04-21 18:09:27 -03:00
fiatjaf
5a8c7df811 fix and simplify nak decode. 2025-04-21 15:37:13 -03:00
fiatjaf
01be954ae6 use badger for outbox hints. 2025-04-21 15:33:49 -03:00
fiatjaf
d733a31898 convert to using nostrlib. 2025-04-20 18:11:21 -03:00
fiatjaf
1b43dbda02 remove dvm command. 2025-04-19 18:07:01 -03:00
fiatjaf
e45b54ea62 fix nak mcp. 2025-04-10 16:59:56 -03:00
fiatjaf
35da063c30 precheck for validity of relay URLs and prevent unwanted crash otherwise. 2025-04-07 23:13:32 -03:00
fiatjaf
15aefe3df4 more examples on readme. 2025-04-03 22:17:30 -03:00
fiatjaf
55fd631787 fix term.GetSize() when piping. 2025-04-03 22:08:11 -03:00
fiatjaf
6f48c29d0f fix go-nostr dependency. 2025-04-03 21:31:12 -03:00
fiatjaf
703c186958 much more colors everywhere and everything is prettier. 2025-04-03 14:50:25 -03:00
fiatjaf
7ae2e686cb more colors. 2025-04-03 11:57:18 -03:00
fiatjaf
9547711e8d nice dynamic UI when connecting to relays, and go much faster concurrently. 2025-04-03 11:42:33 -03:00
fiatjaf
50119e21e6 fix nip73.ExternalPointer reference 2025-04-02 22:38:12 -03:00
fiatjaf
33f4272dd0 update go-nostr to maybe fix nip-60 wallets? 2025-04-02 22:37:59 -03:00
fiatjaf
7b6f387aad tags GetFirst() => Find() 2025-03-29 17:12:31 -03:00
fiatjaf
b1a03800e6 add fake fs command that doesn't work when compiling for windows but at least compiles. 2025-03-19 15:12:39 -03:00
fiatjaf
db5dafb58a fix arm64 builds by removing base64x dependencies. 2025-03-19 15:05:27 -03:00
fiatjaf
4b15cdf625 fs: publishing new notes by writing to ./notes/new 2025-03-15 00:34:13 -03:00
fiatjaf
4b8c067e00 fs: creating articles (and presumably wikis); fixes and improvements to editing articles. 2025-03-13 01:13:34 -03:00
fiatjaf
931da4b0ae fs: editable articles and wiki. 2025-03-12 08:03:10 -03:00
fiatjaf
c87371208e fs: pass NostrRoot everywhere with a signer only if it can actually sign. 2025-03-11 13:18:33 -03:00
fiatjaf
bfe1e6ca94 fs: rename pictures -> photos. 2025-03-11 12:38:35 -03:00
fiatjaf
602e03a9a1 fs: do not paginate videos and highlights (should make this dynamic in the future). 2025-03-11 12:38:32 -03:00
fiatjaf
fe1f50f798 fs: logging and proper (?) handling of context passing (basically now we ignore the context given to us by the fuse library because they're weird). 2025-03-11 12:37:27 -03:00
fiatjaf
d899a92f15 trying to get this thing to build again on github. 2025-03-10 17:36:44 -03:00
fiatjaf
1c058f2846 fix github builds by removing some odd platform combinations. 2025-03-10 17:18:14 -03:00
fiatjaf
4b4d9ec155 fs: some fixes and profile pictures. 2025-03-10 17:07:02 -03:00
44 changed files with 2918 additions and 1576 deletions

View File

@@ -25,7 +25,7 @@ jobs:
strategy:
matrix:
goos: [linux, freebsd, darwin, windows]
goarch: [arm, amd64, arm64, riscv64]
goarch: [amd64, arm64, riscv64]
exclude:
- goarch: arm64
goos: windows
@@ -33,11 +33,7 @@ jobs:
goos: windows
- goarch: riscv64
goos: darwin
- goarch: arm
goos: windows
- goarch: arm
goos: darwin
- goarch: arm
- goarch: arm64
goos: freebsd
steps:
- uses: actions/checkout@v3

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
nak
mnt
nak.exe

View File

@@ -102,7 +102,7 @@ demo videos with [2](https://njump.me/nevent1qqs8pmmae89agph80928l6gjm0wymechqaz
### generate a private key
```shell
~> nak key generate 18:59
~> nak key generate
7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
```
@@ -130,7 +130,7 @@ type the password to decrypt your secret key: **********
### sign an event using a remote NIP-46 bunker
```shell
~> nak event --connect 'bunker://a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Frelay.nsecbunker.com&relay=wss%3A%2F%2Fnos.lol&secret=TWfGbjQCLxUf' -c 'hello from bunker'
~> nak event --sec 'bunker://a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Frelay.nsecbunker.com&relay=wss%3A%2F%2Fnos.lol&secret=TWfGbjQCLxUf' -c 'hello from bunker'
```
### sign an event using a NIP-49 encrypted key
@@ -229,6 +229,48 @@ type the password to decrypt your secret key: ********
~> aria2c $(nak fetch nevent1qqsdsg6x7uujekac4ga7k7qa9q9sx8gqj7xzjf5w9us0dm0ghvf4ugspp4mhxue69uhkummn9ekx7mq6dw9y4 | jq -r '"magnet:?xt=urn:btih:\(tag_value("x"))&dn=\(tag_value("title"))&tr=http%3A%2F%2Ftracker.loadpeers.org%3A8080%2FxvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=\(tags("tracker") | map(.[1] | @uri) | join("&tr="))"')
```
### mount Nostr as a FUSE filesystem and publish a note
```shell
~> nak fs --sec 01 ~/nostr
- mounting at /home/user/nostr... ok.
~> cd ~/nostr/npub1xxxxxx/notes/
~> echo "satellites are bad!" > new
pending note updated, timer reset.
- `touch publish` to publish immediately
- `rm new` to erase and cancel the publication.
~> touch publish
publishing now!
{"id":"f1cbfa6...","pubkey":"...","content":"satellites are bad!","sig":"..."}
publishing to 3 relays... offchain.pub: ok, nostr.wine: ok, pyramid.fiatjaf.com: ok
event published as f1cbfa6... and updated locally.
```
### list NIP-60 wallet tokens and send some
```shell
~> nak wallet tokens
91a10b6fc8bbe7ef2ad9ad0142871d80468b697716d9d2820902db304ff1165e 500 cashu.space
cac7f89f0611021984d92a7daca219e4cd1c9798950e50e952bba7cde1ac1337 1000 legend.lnbits.com
~> nak wallet send 100
cashuA1psxqyry8...
~> nak wallet pay lnbc1...
```
### upload and download files with blossom
```shell
~> nak blossom --server blossom.azzamo.net --sec 01 upload image.png
{"sha256":"38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf","url":"https://blossom.azzamo.net/38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf.png"}
~> nak blossom --server aegis.utxo.one download acc8ea43d4e6b706f68b249144364f446854b7f63ba1927371831c05dcf0256c -o downloaded.png
```
### publish a fully formed event with correct tags, URIs and to the correct read and write relays
```shell
echo "#surely you're joking, mr npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft olas.app is broken again" | nak publish
# it will add the hashtag, turn the npub1 code into a nostr:npub1 URI, turn the olas.app string into https://olas.app, add the "p" tag (and "q" tags too if you were mentioning an nevent1 code or naddr1 code) and finally publish it to your "write" relays and to any mentioned person (or author of mentioned events)'s "read" relays.
# there is also a --reply flag that you can pass an nevent, naddr or hex id to and it will do the right thing (including setting the correct kind to either 1 or 1111).
# and there is a --confirm flag that gives you a chance to confirm before actually publishing the result to relays.
```
## contributing to this repository
Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnwdaehgu3wvfnsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7q3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmej2wctpn`.

View File

@@ -1,12 +1,15 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"github.com/nbd-wtf/go-nostr/keyer"
"github.com/nbd-wtf/go-nostr/nipb0/blossom"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/urfave/cli/v3"
)
@@ -35,7 +38,11 @@ var blossomCmd = &cli.Command{
var client *blossom.Client
pubkey := c.Args().First()
if pubkey != "" {
client = blossom.NewClient(client.GetMediaServer(), keyer.NewReadOnlySigner(pubkey))
if pk, err := nostr.PubKeyFromHex(pubkey); err != nil {
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
} else {
client = blossom.NewClient(client.GetMediaServer(), keyer.NewReadOnlySigner(pk))
}
} else {
var err error
client, err = getBlossomClient(ctx, c)
@@ -68,22 +75,44 @@ var blossomCmd = &cli.Command{
return err
}
hasError := false
for _, fpath := range c.Args().Slice() {
bd, err := client.UploadFile(ctx, fpath)
if isPiped() {
// get file from stdin
if c.Args().Len() > 0 {
return fmt.Errorf("do not pass arguments when piping from stdin")
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasError = true
continue
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)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasError = true
continue
}
j, _ := json.Marshal(bd)
stdout(string(j))
}
if hasError {
os.Exit(3)
}
}
if hasError {
os.Exit(3)
}
return nil
},
},
@@ -125,7 +154,7 @@ var blossomCmd = &cli.Command{
hasError = true
continue
}
os.Stdout.Write(data)
stdout(data)
}
}

View File

@@ -10,11 +10,11 @@ import (
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip46"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46"
)
var bunker = &cli.Command{
@@ -38,7 +38,7 @@ var bunker = &cli.Command{
Aliases: []string{"s"},
Usage: "secrets for which we will always respond",
},
&cli.StringSliceFlag{
&PubKeySliceFlag{
Name: "authorized-keys",
Aliases: []string{"k"},
Usage: "pubkeys for which we will always respond",
@@ -49,7 +49,7 @@ var bunker = &cli.Command{
qs := url.Values{}
relayURLs := make([]string, 0, c.Args().Len())
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays := connectToAllRelays(ctx, relayUrls, false)
relays := connectToAllRelays(ctx, c, relayUrls, nil, nostr.PoolOptions{})
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -70,7 +70,7 @@ var bunker = &cli.Command{
}
// other arguments
authorizedKeys := c.StringSlice("authorized-keys")
authorizedKeys := getPubKeySlice(c, "authorized-keys")
authorizedSecrets := c.StringSlice("authorized-secrets")
// this will be used to auto-authorize the next person who connects who isn't pre-authorized
@@ -78,20 +78,20 @@ var bunker = &cli.Command{
newSecret := randString(12)
// static information
pubkey, err := nostr.GetPublicKey(sec)
if err != nil {
return err
}
npub, _ := nip19.EncodePublicKey(pubkey)
pubkey := sec.Public()
npub := nip19.EncodeNpub(pubkey)
// this function will be called every now and then
printBunkerInfo := func() {
qs.Set("secret", newSecret)
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey, qs.Encode())
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
authorizedKeysStr := ""
if len(authorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:\n - " + colors.italic(strings.Join(authorizedKeys, "\n - "))
authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range authorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
}
}
authorizedSecretsStr := ""
@@ -101,7 +101,7 @@ var bunker = &cli.Command{
preauthorizedFlags := ""
for _, k := range authorizedKeys {
preauthorizedFlags += " -k " + k
preauthorizedFlags += " -k " + k.Hex()
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
@@ -129,7 +129,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(pubkey),
colors.bold(pubkey.Hex()),
colors.bold(npub),
authorizedKeysStr,
authorizedSecretsStr,
@@ -140,12 +140,13 @@ var bunker = &cli.Command{
printBunkerInfo()
// subscribe to relays
now := nostr.Now()
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
Kinds: []int{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey}},
Since: &now,
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
signer := nip46.NewStaticKeySigner(sec)
@@ -158,7 +159,7 @@ var bunker = &cli.Command{
cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from string, secret string) bool {
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if secret == newSecret {
// store this key
authorizedKeys = append(authorizedKeys, from)
@@ -185,7 +186,7 @@ var bunker = &cli.Command{
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey), string(jreq))
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
@@ -236,11 +237,13 @@ var bunker = &cli.Command{
}
uri, err := url.Parse(c.Args().First())
if err != nil || uri.Scheme != "nostrconnect" || !nostr.IsValidPublicKey(uri.Host) {
if err != nil || uri.Scheme != "nostrconnect" {
return fmt.Errorf("invalid uri")
}
return nil
// TODO
return fmt.Errorf("this is not implemented yet")
},
},
},

232
cli_test.go Normal file
View File

@@ -0,0 +1,232 @@
package main
import (
"encoding/hex"
stdjson "encoding/json"
"fmt"
"strings"
"testing"
"fiatjaf.com/nostr"
"github.com/stretchr/testify/require"
)
// these tests are tricky because commands and flags are declared as globals and values set in one call may persist
// to the next. for example, if in the first test we set --limit 2 then doesn't specify --limit in the second then
// it will still return true for cmd.IsSet("limit") and then we will set .LimitZero = true
func call(t *testing.T, cmd string) string {
var output strings.Builder
stdout = func(a ...any) (int, error) {
output.WriteString(fmt.Sprint(a...))
output.WriteString("\n")
return 0, nil
}
err := app.Run(t.Context(), strings.Split(cmd, " "))
require.NoError(t, err)
return strings.TrimSpace(output.String())
}
func TestEventBasic(t *testing.T) {
output := call(t, "nak event --ts 1699485669")
var evt nostr.Event
err := stdjson.Unmarshal([]byte(output), &evt)
require.NoError(t, err)
require.Equal(t, nostr.Kind(1), evt.Kind)
require.Equal(t, nostr.Timestamp(1699485669), evt.CreatedAt)
require.Equal(t, "hello from the nostr army knife", evt.Content)
require.Equal(t, "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", evt.ID.Hex())
require.Equal(t, "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", evt.PubKey.Hex())
require.Equal(t, "68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2", hex.EncodeToString(evt.Sig[:]))
}
func TestEventComplex(t *testing.T) {
output := call(t, "nak event --ts 1699485669 -k 11 -c skjdbaskd --sec 17 -t t=spam -e 36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c -t r=https://abc.def?name=foobar;nothing")
var evt nostr.Event
err := stdjson.Unmarshal([]byte(output), &evt)
require.NoError(t, err)
require.Equal(t, nostr.Kind(11), evt.Kind)
require.Equal(t, nostr.Timestamp(1699485669), evt.CreatedAt)
require.Equal(t, "skjdbaskd", evt.Content)
require.Equal(t, "19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179", evt.ID.Hex())
require.Equal(t, "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", evt.PubKey.Hex())
require.Equal(t, "cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7", hex.EncodeToString(evt.Sig[:]))
require.Len(t, evt.Tags, 3)
require.Equal(t, nostr.Tag{"t", "spam"}, evt.Tags[0])
require.Equal(t, nostr.Tag{"r", "https://abc.def?name=foobar", "nothing"}, evt.Tags[1])
require.Equal(t, nostr.Tag{"e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"}, evt.Tags[2])
}
func TestEncode(t *testing.T) {
require.Equal(t,
"npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28",
call(t, "nak encode npub a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"),
)
require.Equal(t,
`nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
nprofile1qqs22kfpwwt4mmvlsd4f2uh23vg60ctvadnyvntx659jw93l0upe6tqpz9mhxue69uhk27rpd4cxcefwvdhk64h265a`,
call(t, "nak encode nprofile -r wss://example.com a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822 a5592173975ded9f836a9572ea8b11a7e16ceb66464d66d50b27163f7f039d2c"),
)
}
func TestDecodeNaddr(t *testing.T) {
output := call(t, "nak decode naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu")
var result map[string]interface{}
err := stdjson.Unmarshal([]byte(output), &result)
require.NoError(t, err)
require.Equal(t, "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e", result["pubkey"])
require.Equal(t, float64(31923), result["kind"])
require.Equal(t, "4cd6cfe7", result["identifier"])
require.Equal(t, []interface{}{"wss://nos.lol"}, result["relays"])
}
func TestDecodePubkey(t *testing.T) {
output := call(t, "nak decode -p npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd")
expected := "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\nc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
require.Equal(t, expected, output)
}
func TestDecodeMultipleNpubs(t *testing.T) {
output := call(t, "nak decode npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk")
require.Len(t, strings.Split(output, "\n"), 2)
}
func TestDecodeEventId(t *testing.T) {
output := call(t, "nak decode -e nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8 nevent1qqswh48lurxs8u0pll9qj2rzctvjncwhstpzlstq59rdtzlty79awns5hl5uf")
expected := "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5\nebd4ffe0cd03f1e1ffca092862c2d929e1d782c22fc160a146d58beb278bd74e"
require.Equal(t, expected, output)
}
func TestReq(t *testing.T) {
output := call(t, "nak req -k 1 -l 18 -a 2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f -e aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6")
var result []interface{}
err := stdjson.Unmarshal([]byte(output), &result)
require.NoError(t, err)
require.Equal(t, "REQ", result[0])
require.Equal(t, "nak", result[1])
filter := result[2].(map[string]interface{})
require.Equal(t, []interface{}{float64(1)}, filter["kinds"])
require.Equal(t, []interface{}{"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"}, filter["authors"])
require.Equal(t, float64(18), filter["limit"])
require.Equal(t, []interface{}{"aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"}, filter["#e"])
}
func TestMultipleFetch(t *testing.T) {
output := call(t, "nak fetch naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8")
var events []nostr.Event
for _, line := range strings.Split(output, "\n") {
var evt nostr.Event
err := stdjson.Unmarshal([]byte(line), &evt)
require.NoError(t, err)
events = append(events, evt)
}
require.Len(t, events, 2)
// First event validation
require.Equal(t, nostr.Kind(31923), events[0].Kind)
require.Equal(t, "9ae5014573fc75ced00b343868d2cd9343ebcbbae50591c6fa8ae1cd99568f05", events[0].ID.Hex())
require.Equal(t, "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e", events[0].PubKey.Hex())
require.Equal(t, nostr.Timestamp(1707764605), events[0].CreatedAt)
// Second event validation
require.Equal(t, nostr.Kind(1), events[1].Kind)
require.Equal(t, "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5", events[1].ID.Hex())
require.Equal(t, "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", events[1].PubKey.Hex())
require.Equal(t, nostr.Timestamp(1710759386), events[1].CreatedAt)
}
func TestKeyPublic(t *testing.T) {
output := call(t, "nak key public 3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")
expected := "70f7120d065870513a6bddb61c8d400ad1e43449b1900ffdb5551e4c421375c8\n718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029"
require.Equal(t, expected, output)
}
func TestKeyDecrypt(t *testing.T) {
output := call(t, "nak key decrypt ncryptsec1qgg2gx2a7hxpsse2zulrv7m8qwccvl3mh8e9k8vtz3wpyrwuuclaq73gz7ddt5kpa93qyfhfjakguuf8uhw90jn6mszh7kqeh9mxzlyw8hy75fluzx4h75frwmu2yngsq7hx7w32d0vdyxyns5g6rqft banana")
require.Equal(t, "718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029", output)
}
func TestReqIdFromRelay(t *testing.T) {
output := call(t, "nak req -i 20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da --limit 1 nos.lol")
var evt nostr.Event
err := stdjson.Unmarshal([]byte(output), &evt)
require.NoError(t, err)
require.Equal(t, nostr.Kind(1), evt.Kind)
require.Equal(t, "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", evt.ID.Hex())
require.Equal(t, "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319", evt.PubKey.Hex())
require.Equal(t, nostr.Timestamp(1720972243), evt.CreatedAt)
require.Equal(t, "Yeah, so bizarre, but I guess most people are meant to be serfs.", evt.Content)
}
func TestReqWithFlagsAfter1(t *testing.T) {
output := call(t, "nak req nos.lol -i 20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da --limit 1")
var evt nostr.Event
err := stdjson.Unmarshal([]byte(output), &evt)
require.NoError(t, err)
require.Equal(t, nostr.Kind(1), evt.Kind)
require.Equal(t, "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", evt.ID.Hex())
require.Equal(t, "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319", evt.PubKey.Hex())
require.Equal(t, nostr.Timestamp(1720972243), evt.CreatedAt)
require.Equal(t, "Yeah, so bizarre, but I guess most people are meant to be serfs.", evt.Content)
}
func TestReqWithFlagsAfter2(t *testing.T) {
output := call(t, "nak req -e 893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1 nostr.mom --author 2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6 --limit 1 -k 7")
var evt nostr.Event
err := stdjson.Unmarshal([]byte(output), &evt)
require.NoError(t, err)
require.Equal(t, nostr.Kind(7), evt.Kind)
require.Equal(t, "9b4868b068ea34ae51092807586c4541b3569d9efc23862aea48ef13de275857", evt.ID.Hex())
require.Equal(t, "2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6", evt.PubKey.Hex())
require.Equal(t, nostr.Timestamp(1720987327), evt.CreatedAt)
require.Equal(t, "❤️", evt.Content)
}
func TestReqWithFlagsAfter3(t *testing.T) {
output := call(t, "nak req --limit 1 pyramid.fiatjaf.com -a 3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24 -qp 3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24 -e 9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188")
var evt nostr.Event
err := stdjson.Unmarshal([]byte(output), &evt)
require.NoError(t, err)
require.Equal(t, nostr.Kind(1), evt.Kind)
require.Equal(t, "101572c80ebdc963dab8440f6307387a3023b6d90f7e495d6c5ee1ef77045a67", evt.ID.Hex())
require.Equal(t, "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", evt.PubKey.Hex())
require.Equal(t, nostr.Timestamp(1720987305), evt.CreatedAt)
require.Equal(t, "Nope. I grew up playing in the woods. Never once saw a bear in the woods. If I did, I'd probably shiy my pants, then scream at it like I was a crazy person with my arms above my head to make me seem huge.", evt.Content)
}
func TestNaturalTimestamps(t *testing.T) {
output := call(t, "nak event -t plu=pla -e 3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24 --ts '2018-May-19T03:37:19' -c nn")
var evt nostr.Event
err := stdjson.Unmarshal([]byte(output), &evt)
require.NoError(t, err)
require.Equal(t, nostr.Kind(1), evt.Kind)
require.Equal(t, "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", evt.PubKey.Hex())
require.Equal(t, nostr.Timestamp(1526711839), evt.CreatedAt)
require.Equal(t, "nn", evt.Content)
}

View File

@@ -6,10 +6,10 @@ import (
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip45"
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
)
var count = &cli.Command{
@@ -18,7 +18,7 @@ var count = &cli.Command{
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringSliceFlag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
@@ -46,13 +46,13 @@ var count = &cli.Command{
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
@@ -70,10 +70,7 @@ var count = &cli.Command{
biggerUrlSize := 0
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
relays := connectToAllRelays(ctx,
relayUrls,
false,
)
relays := connectToAllRelays(ctx, c, relayUrls, nil, nostr.PoolOptions{})
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -85,26 +82,17 @@ var count = &cli.Command{
biggerUrlSize = len(relay.URL)
}
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
}
filter := nostr.Filter{}
if authors := c.StringSlice("author"); len(authors) > 0 {
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = authors
}
if ids := c.StringSlice("id"); len(ids) > 0 {
filter.IDs = ids
}
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
kinds := make([]int, len(kinds64))
kinds := make([]nostr.Kind, len(kinds64))
for i, v := range kinds64 {
kinds[i] = int(v)
kinds[i] = nostr.Kind(v)
}
filter.Kinds = kinds
}
@@ -134,14 +122,13 @@ var count = &cli.Command{
}
}
if since := c.Int("since"); since != 0 {
ts := nostr.Timestamp(since)
filter.Since = &ts
if c.IsSet("since") {
filter.Since = getNaturalDate(c, "since")
}
if until := c.Int("until"); until != 0 {
ts := nostr.Timestamp(until)
filter.Until = &ts
if c.IsSet("until") {
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = int(limit)
}
@@ -154,7 +141,7 @@ var count = &cli.Command{
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, nostr.Filters{filter})
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {

View File

@@ -8,7 +8,7 @@ import (
"os/exec"
"strings"
"github.com/nbd-wtf/go-nostr"
"fiatjaf.com/nostr"
"github.com/urfave/cli/v3"
"golang.org/x/exp/slices"
)

117
decode.go
View File

@@ -3,12 +3,13 @@ package main
import (
"context"
"encoding/hex"
stdjson "encoding/json"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/sdk"
)
var decode = &cli.Command{
@@ -39,88 +40,56 @@ var decode = &cli.Command{
input = input[6:]
}
var decodeResult DecodeResult
if b, err := hex.DecodeString(input); err == nil {
if len(b) == 64 {
decodeResult.HexResult.PossibleTypes = []string{"sig"}
decodeResult.HexResult.Signature = hex.EncodeToString(b)
} else if len(b) == 32 {
decodeResult.HexResult.PossibleTypes = []string{"pubkey", "private_key", "event_id"}
decodeResult.HexResult.ID = hex.EncodeToString(b)
decodeResult.HexResult.PrivateKey = hex.EncodeToString(b)
decodeResult.HexResult.PublicKey = hex.EncodeToString(b)
} else {
ctx = lineProcessingError(ctx, "hex string with invalid number of bytes: %d", len(b))
_, data, err := nip19.Decode(input)
if err == nil {
switch v := data.(type) {
case nostr.SecretKey:
stdout(v.Hex())
continue
case nostr.PubKey:
stdout(v.Hex())
continue
case [32]byte:
stdout(hex.EncodeToString(v[:]))
continue
case nostr.EventPointer:
if c.Bool("id") {
stdout(v.ID.Hex())
continue
}
out, _ := stdjson.MarshalIndent(v, "", " ")
stdout(string(out))
continue
case nostr.ProfilePointer:
if c.Bool("pubkey") {
stdout(v.PublicKey.Hex())
continue
}
out, _ := stdjson.MarshalIndent(v, "", " ")
stdout(string(out))
continue
case nostr.EntityPointer:
out, _ := stdjson.MarshalIndent(v, "", " ")
stdout(string(out))
continue
}
} else if evp := sdk.InputToEventPointer(input); evp != nil {
decodeResult = DecodeResult{EventPointer: evp}
if c.Bool("id") {
stdout(evp.ID)
continue
}
} else if pp := sdk.InputToProfile(ctx, input); pp != nil {
decodeResult = DecodeResult{ProfilePointer: pp}
}
pp, _ := nip05.QueryIdentifier(ctx, input)
if pp != nil {
if c.Bool("pubkey") {
stdout(pp.PublicKey)
stdout(pp.PublicKey.Hex())
continue
}
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "naddr" {
if ep, ok := value.(nostr.EntityPointer); ok {
decodeResult = DecodeResult{EntityPointer: &ep}
} else {
ctx = lineProcessingError(ctx, "couldn't decode naddr: %s", err)
}
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "nsec" {
decodeResult.PrivateKey.PrivateKey = value.(string)
decodeResult.PrivateKey.PublicKey, _ = nostr.GetPublicKey(value.(string))
} else {
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
out, _ := stdjson.MarshalIndent(pp, "", " ")
stdout(string(out))
continue
}
if c.Bool("pubkey") || c.Bool("id") {
return nil
}
stdout(decodeResult.JSON())
ctx = lineProcessingError(ctx, "couldn't decode input '%s'", input)
}
exitIfLineProcessingError(ctx)
return nil
},
}
type DecodeResult struct {
*nostr.EventPointer
*nostr.ProfilePointer
*nostr.EntityPointer
HexResult struct {
PossibleTypes []string `json:"possible_types"`
PublicKey string `json:"pubkey,omitempty"`
ID string `json:"event_id,omitempty"`
PrivateKey string `json:"private_key,omitempty"`
Signature string `json:"sig,omitempty"`
}
PrivateKey struct {
nostr.ProfilePointer
PrivateKey string `json:"private_key"`
}
}
func (d DecodeResult) JSON() string {
var j []byte
if d.EventPointer != nil {
j, _ = json.MarshalIndent(d.EventPointer, "", " ")
} else if d.ProfilePointer != nil {
j, _ = json.MarshalIndent(d.ProfilePointer, "", " ")
} else if d.EntityPointer != nil {
j, _ = json.MarshalIndent(d.EntityPointer, "", " ")
} else if len(d.HexResult.PossibleTypes) > 0 {
j, _ = json.MarshalIndent(d.HexResult, "", " ")
} else if d.PrivateKey.PrivateKey != "" {
j, _ = json.MarshalIndent(d.PrivateKey, "", " ")
}
return string(j)
}

137
dvm.go
View File

@@ -1,137 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip90"
"github.com/urfave/cli/v3"
)
var dvm = &cli.Command{
Name: "dvm",
Usage: "deal with nip90 data-vending-machine things (experimental)",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
},
),
Commands: append([]*cli.Command{
{
Name: "list",
Usage: "find DVMs that have announced themselves for a specific kind",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("we don't know how to do this yet")
},
},
}, (func() []*cli.Command {
commands := make([]*cli.Command, len(nip90.Jobs))
for i, job := range nip90.Jobs {
flags := make([]cli.Flag, 0, 2+len(job.Params))
if job.InputType != "" {
flags = append(flags, &cli.StringSliceFlag{
Name: "input",
Aliases: []string{"i"},
Category: "INPUT",
})
}
for _, param := range job.Params {
flags = append(flags, &cli.StringSliceFlag{
Name: param,
Category: "PARAMETER",
})
}
commands[i] = &cli.Command{
Name: strconv.Itoa(job.InputKind),
Usage: job.Name,
Description: job.Description,
DisableSliceFlagSeparator: true,
Flags: flags,
Action: func(ctx context.Context, c *cli.Command) error {
relayUrls := c.StringSlice("relay")
relays := connectToAllRelays(ctx, relayUrls, false)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
evt := nostr.Event{
Kind: job.InputKind,
Tags: make(nostr.Tags, 0, 2+len(job.Params)),
CreatedAt: nostr.Now(),
}
for _, input := range c.StringSlice("input") {
evt.Tags = append(evt.Tags, nostr.Tag{"i", input, job.InputType})
}
for _, paramN := range job.Params {
for _, paramV := range c.StringSlice(paramN) {
tag := nostr.Tag{"param", paramN, "", ""}[0:2]
for _, v := range strings.Split(paramV, ";") {
tag = append(tag, v)
}
evt.Tags = append(evt.Tags, tag)
}
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return err
}
logverbose("%s", evt)
log("- publishing job request... ")
first := true
for res := range sys.Pool.PublishMany(ctx, relayUrls, evt) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n- waiting for response...\n")
for ie := range sys.Pool.SubscribeMany(ctx, relayUrls, nostr.Filter{
Kinds: []int{7000, job.OutputKind},
Tags: nostr.TagMap{"e": []string{evt.ID}},
}) {
stdout(ie.Event)
}
return nil
},
}
}
return commands
})()...),
}

145
encode.go
View File

@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/urfave/cli/v3"
)
@@ -18,14 +18,68 @@ var encode = &cli.Command{
nak encode nprofile --relay <relay-url> <pubkey-hex>
nak encode nevent <event-id>
nak encode nevent --author <pubkey-hex> --relay <relay-url> --relay <other-relay> <event-id>
nak encode nsec <privkey-hex>`,
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
if c.Args().Len() < 1 {
return ctx, fmt.Errorf("expected more than 1 argument.")
}
return ctx, nil
nak encode nsec <privkey-hex>
echo '{"pubkey":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19","relays":["wss://nada.zero"]}' | nak encode
echo '{
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19"
"relays":["wss://nada.zero"],
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
} | nak encode`,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to naddr code",
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 0 {
return nil
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
hasStdin := false
for jsonStr := range getJsonsOrBlank() {
if jsonStr == "{}" {
hasStdin = false
continue
} else {
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))
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...)))
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...)))
continue
}
ctx = lineProcessingError(ctx, "couldn't decode JSON '%s'", jsonStr)
}
if !hasStdin {
return nil
}
exitIfLineProcessingError(ctx)
return nil
},
Commands: []*cli.Command{
{
Name: "npub",
@@ -33,16 +87,13 @@ var encode = &cli.Command{
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValidPublicKey(target); !ok {
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
pk, err := nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
continue
}
if npub, err := nip19.EncodePublicKey(target); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNpub(pk))
}
exitIfLineProcessingError(ctx)
@@ -55,16 +106,13 @@ var encode = &cli.Command{
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
ctx = lineProcessingError(ctx, "invalid private key: %s", target)
sk, err := nostr.SecretKeyFromHex(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid private key '%s': %w", target, err)
continue
}
if npub, err := nip19.EncodePrivateKey(target); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNsec(sk))
}
exitIfLineProcessingError(ctx)
@@ -84,8 +132,9 @@ var encode = &cli.Command{
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
pk, err := nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
continue
}
@@ -94,11 +143,7 @@ var encode = &cli.Command{
return err
}
if npub, err := nip19.EncodeProfile(target, relays); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNprofile(pk, relays))
}
exitIfLineProcessingError(ctx)
@@ -109,12 +154,7 @@ var encode = &cli.Command{
Name: "nevent",
Usage: "generate event codes with optionally attached relay information",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to nevent code",
},
&cli.StringFlag{
&PubKeyFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "attach an author pubkey as a hint to the nevent code",
@@ -123,28 +163,19 @@ var encode = &cli.Command{
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
id, err := nostr.IDFromHex(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue
}
author := c.String("author")
if author != "" {
if ok := nostr.IsValidPublicKey(author); !ok {
return fmt.Errorf("invalid 'author' public key")
}
}
author := getPubKey(c, "author")
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
if npub, err := nip19.EncodeEvent(target, relays, author); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNevent(id, relays, author))
}
exitIfLineProcessingError(ctx)
@@ -161,7 +192,7 @@ var encode = &cli.Command{
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
Required: true,
},
&cli.StringFlag{
&PubKeyFlag{
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
@@ -173,19 +204,11 @@ var encode = &cli.Command{
Usage: "kind of referred replaceable event",
Required: true,
},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to naddr code",
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for d := range getStdinLinesOrBlank() {
pubkey := c.String("pubkey")
if ok := nostr.IsValidPublicKey(pubkey); !ok {
return fmt.Errorf("invalid 'pubkey'")
}
pubkey := getPubKey(c, "pubkey")
kind := c.Int("kind")
if kind < 30000 || kind >= 40000 {
@@ -205,11 +228,7 @@ var encode = &cli.Command{
return err
}
if npub, err := nip19.EncodeEntity(pubkey, int(kind), d, relays); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNaddr(pubkey, nostr.Kind(kind), d, relays))
}
exitIfLineProcessingError(ctx)

View File

@@ -4,9 +4,8 @@ import (
"context"
"fmt"
"fiatjaf.com/nostr/nip04"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip04"
)
var encrypt = &cli.Command{
@@ -16,7 +15,7 @@ var encrypt = &cli.Command{
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
&cli.StringFlag{
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey"},
Required: true,
@@ -27,10 +26,7 @@ var encrypt = &cli.Command{
},
),
Action: func(ctx context.Context, c *cli.Command) error {
target := c.String("recipient-pubkey")
if !nostr.IsValidPublicKey(target) {
return fmt.Errorf("target %s is not a valid public key", target)
}
target := getPubKey(c, "recipient-pubkey")
plaintext := c.Args().First()
@@ -81,7 +77,7 @@ var decrypt = &cli.Command{
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
&cli.StringFlag{
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey"},
Required: true,
@@ -92,10 +88,7 @@ var decrypt = &cli.Command{
},
),
Action: func(ctx context.Context, c *cli.Command) error {
source := c.String("sender-pubkey")
if !nostr.IsValidPublicKey(source) {
return fmt.Errorf("source %s is not a valid public key", source)
}
source := getPubKey(c, "sender-pubkey")
ciphertext := c.Args().First()

210
event.go
View File

@@ -8,10 +8,11 @@ import (
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip13"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip13"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/urfave/cli/v3"
)
@@ -128,31 +129,35 @@ example:
Value: nostr.Now(),
Category: CATEGORY_EVENT_FIELDS,
},
&cli.BoolFlag{
Name: "confirm",
Usage: "ask before publishing the event",
Category: CATEGORY_EXTRAS,
},
),
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, relayUrls, false)
relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthHandler: 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)
}
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
doAuth := c.Bool("auth")
// then process input and generate events:
// will reuse this
@@ -170,7 +175,7 @@ example:
}
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
evt.Kind = int(kind)
evt.Kind = nostr.Kind(kind)
mustRehashAndResign = true
} else if !kindWasSupplied {
evt.Kind = 1
@@ -209,13 +214,19 @@ example:
}
for _, etag := range c.StringSlice("e") {
tags = tags.AppendUnique([]string{"e", etag})
if tags.FindWithValue("e", etag) == nil {
tags = append(tags, nostr.Tag{"e", etag})
}
}
for _, ptag := range c.StringSlice("p") {
tags = tags.AppendUnique([]string{"p", ptag})
if tags.FindWithValue("p", ptag) == nil {
tags = append(tags, nostr.Tag{"p", ptag})
}
}
for _, dtag := range c.StringSlice("d") {
tags = tags.AppendUnique([]string{"d", dtag})
if tags.FindWithValue("d", dtag) == nil {
tags = append(tags, nostr.Tag{"d", dtag})
}
}
if len(tags) > 0 {
for _, tag := range tags {
@@ -258,7 +269,7 @@ example:
mustRehashAndResign = true
}
if evt.Sig == "" || mustRehashAndResign {
if evt.Sig == [64]byte{} || mustRehashAndResign {
if numSigners := c.Uint("musig"); numSigners > 1 {
// must do musig
pubkeys := c.StringSlice("musig-pubkey")
@@ -291,48 +302,7 @@ example:
}
stdout(result)
// publish to relays
successRelays := make([]string, 0, len(relays))
if len(relays) > 0 {
os.Stdout.Sync()
for _, relay := range relays {
publish:
log("publishing to %s... ", relay.URL)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := relay.Publish(ctx, evt)
if err == nil {
// published fine
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth {
// if the relay is requesting auth and we can auth, let's do it
pk, _ := kr.GetPublicKey(ctx)
log("performing auth as %s... ", pk)
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
return kr.SignEvent(ctx, authEvent)
}); err == nil {
// try to publish again, but this time don't try to auth again
doAuth = false
goto publish
} else {
log("auth error: %s. ", err)
}
}
log("failed: %s\n", err)
}
if len(successRelays) > 0 && c.Bool("nevent") {
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
log(nevent + "\n")
}
}
return nil
return publishFlow(ctx, c, kr, evt, relays)
}
for stdinEvent := range getJsonsOrBlank() {
@@ -345,3 +315,125 @@ example:
return nil
},
}
func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr.Event, relays []*nostr.Relay) error {
doAuth := c.Bool("auth")
// publish to relays
successRelays := make([]string, 0, len(relays))
if len(relays) > 0 {
os.Stdout.Sync()
if c.Bool("confirm") {
relaysStr := make([]string, len(relays))
for i, r := range relays {
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
}
time.Sleep(time.Millisecond * 10)
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
return nil
}
}
if supportsDynamicMultilineMagic() {
// overcomplicated multiline rendering magic
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
urls := make([]string, len(relays))
lines := make([][][]byte, len(urls))
flush := func() {
for _, line := range lines {
for _, part := range line {
os.Stderr.Write(part)
}
os.Stderr.Write([]byte{'\n'})
}
}
render := func() {
clearLines(len(lines))
flush()
}
flush()
logthis := func(relayUrl, s string, args ...any) {
idx := slices.Index(urls, relayUrl)
lines[idx] = append(lines[idx], []byte(fmt.Sprintf(s, args...)))
render()
}
colorizethis := func(relayUrl string, colorize func(string, ...any) string) {
cleanUrl, _ := strings.CutPrefix(relayUrl, "wss://")
idx := slices.Index(urls, relayUrl)
lines[idx][0] = []byte(fmt.Sprintf("publishing to %s... ", colorize(cleanUrl)))
render()
}
for i, relay := range relays {
urls[i] = relay.URL
lines[i] = make([][]byte, 1, 3)
colorizethis(relay.URL, color.CyanString)
}
render()
for res := range sys.Pool.PublishMany(ctx, urls, evt) {
if res.Error == nil {
colorizethis(res.RelayURL, colors.successf)
logthis(res.RelayURL, "success.")
successRelays = append(successRelays, res.RelayURL)
} else {
colorizethis(res.RelayURL, colors.errorf)
// in this case it's likely that the lowest-level error is the one that will be more helpful
low := unwrapAll(res.Error)
// hack for some messages such as from relay.westernbtc.com
msg := strings.ReplaceAll(low.Error(), evt.PubKey.Hex(), "author")
// do not allow the message to overflow the term window
msg = clampMessage(msg, 20+len(res.RelayURL))
logthis(res.RelayURL, msg)
}
}
} else {
// normal dumb flow
for _, relay := range relays {
publish:
cleanUrl, _ := strings.CutPrefix(relay.URL, "wss://")
log("publishing to %s... ", color.CyanString(cleanUrl))
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := relay.Publish(ctx, evt)
if err == nil {
// published fine
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth {
// if the relay is requesting auth and we can auth, let's do it
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
if err := relay.Auth(ctx, kr.SignEvent); err == nil {
// try to publish again, but this time don't try to auth again
doAuth = false
goto publish
} else {
log("auth error: %s. ", err)
}
}
log("failed: %s\n", err)
}
}
if len(successRelays) > 0 && c.Bool("nevent") {
log(nip19.EncodeNevent(evt.ID, successRelays, evt.PubKey) + "\n")
}
}
return nil
}

View File

@@ -1,138 +0,0 @@
package main
import (
"context"
)
// these tests are tricky because commands and flags are declared as globals and values set in one call may persist
// to the next. for example, if in the first test we set --limit 2 then doesn't specify --limit in the second then
// it will still return true for cmd.IsSet("limit") and then we will set .LimitZero = true
var ctx = context.Background()
func ExampleEventBasic() {
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669"})
// Output:
// {"kind":1,"id":"36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"tags":[],"content":"hello from the nostr army knife","sig":"68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2"}
}
// (for some reason there can only be one test dealing with stdin in the suite otherwise it halts)
// func ExampleEventParsingFromStdin() {
// prevStdin := os.Stdin
// defer func() { os.Stdin = prevStdin }()
// r, w, _ := os.Pipe()
// os.Stdin = r
// w.WriteString("{\"content\":\"hello world\"}\n{\"content\":\"hello sun\"}\n")
// app.Run(ctx, []string{"nak", "event", "-t", "t=spam", "--ts", "1699485669"})
// // Output:
// // {"id":"bda134f9077c11973afe6aa5a1cc6f5bcea01c40d318b8f91dcb8e50507cfa52","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello world","sig":"7552454bb8e7944230142634e3e34ac7468bad9b21ed6909da572c611018dff1d14d0792e98b5806f6330edc51e09efa6d0b66a9694dc34606c70f4e580e7493"}
// // {"id":"879c36ec73acca288825b53585389581d3836e7f0fe4d46e5eba237ca56d6af5","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello sun","sig":"6c7e6b13ebdf931d26acfdd00bec2ec1140ddaf8d1ed61453543a14e729a460fe36c40c488ccb194a0e1ab9511cb6c36741485f501bdb93c39ca4c51bc59cbd4"}
// }
func ExampleEventComplex() {
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669", "-k", "11", "-c", "skjdbaskd", "--sec", "17", "-t", "t=spam", "-e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", "-t", "r=https://abc.def?name=foobar;nothing"})
// Output:
// {"kind":11,"id":"19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179","pubkey":"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f","created_at":1699485669,"tags":[["t","spam"],["r","https://abc.def?name=foobar","nothing"],["e","36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"]],"content":"skjdbaskd","sig":"cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7"}
}
func ExampleEncode() {
app.Run(ctx, []string{"nak", "encode", "npub", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822", "a5592173975ded9f836a9572ea8b11a7e16ceb66464d66d50b27163f7f039d2c"})
// npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
// nprofile1qqs22kfpwwt4mmvlsd4f2uh23vg60ctvadnyvntx659jw93l0upe6tqpz9mhxue69uhk27rpd4cxcefwvdhk64h265a
}
func ExampleDecode() {
app.Run(ctx, []string{"nak", "decode", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
// Output:
// {
// "pubkey": "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e",
// "kind": 31923,
// "identifier": "4cd6cfe7",
// "relays": [
// "wss://nos.lol"
// ]
// }
// {
// "id": "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5",
// "relays": [
// "wss://pyramid.fiatjaf.com/",
// "wss://relay.westernbtc.com/",
// "wss://relay.snort.social/",
// "wss://atlas.nostr.land/"
// ]
// }
}
func ExampleDecodePubkey() {
app.Run(ctx, []string{"nak", "decode", "-p", "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d", "npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd"})
// Output:
// 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
// c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5
}
func ExampleDecodeEventId() {
app.Run(ctx, []string{"nak", "decode", "-e", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8", "nevent1qqswh48lurxs8u0pll9qj2rzctvjncwhstpzlstq59rdtzlty79awns5hl5uf"})
// Output:
// 3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5
// ebd4ffe0cd03f1e1ffca092862c2d929e1d782c22fc160a146d58beb278bd74e
}
func ExampleReq() {
app.Run(ctx, []string{"nak", "req", "-k", "1", "-l", "18", "-a", "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", "-e", "aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"})
// Output:
// ["REQ","nak",{"kinds":[1],"authors":["2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"],"limit":18,"#e":["aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"]}]
}
func ExampleMultipleFetch() {
app.Run(ctx, []string{"nak", "fetch", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
// Output:
// {"kind":31923,"id":"9ae5014573fc75ced00b343868d2cd9343ebcbbae50591c6fa8ae1cd99568f05","pubkey":"5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","created_at":1707764605,"tags":[["d","4cd6cfe7"],["name","Nostr PHX Presents Culture Shock"],["description","Nostr PHX presents Culture Shock the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, food, drinks, conversation, vendors and best of all, a live convert which will stream globally for the world to zap. "],["start","1708185600"],["end","1708228800"],["start_tzid","America/Phoenix"],["p","5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","","host"],["location","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["address","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["g","9tbq1rzn"],["image","https://flockstr.s3.amazonaws.com/event/15vSaiscDhVH1KBXhA0i8"],["about","Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. "],["calendar","31924:5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e:1f238c94"]],"content":"Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. ","sig":"f676629d1414d96b464644de6babde0c96bd21ef9b41ba69ad886a1d13a942b855b715b22ccf38bc07fead18d3bdeee82d9e3825cf6f003fb5ff1766d95c70a0"}
// {"kind":1,"id":"3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1710759386,"tags":[],"content":"Nostr was coopted by our the corporate overlords. It is now featured in https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml.","sig":"faaec167cca4de50b562b7702e8854e2023f0ccd5f36d1b95b6eac20d352206342d6987e9516d283068c768e94dbe8858e2990c3e05405e707fb6fb771ef92f9"}
}
func ExampleKeyPublic() {
app.Run(ctx, []string{"nak", "key", "public", "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"})
// Output:
// 70f7120d065870513a6bddb61c8d400ad1e43449b1900ffdb5551e4c421375c8
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
}
func ExampleKeyDecrypt() {
app.Run(ctx, []string{"nak", "key", "decrypt", "ncryptsec1qgg2gx2a7hxpsse2zulrv7m8qwccvl3mh8e9k8vtz3wpyrwuuclaq73gz7ddt5kpa93qyfhfjakguuf8uhw90jn6mszh7kqeh9mxzlyw8hy75fluzx4h75frwmu2yngsq7hx7w32d0vdyxyns5g6rqft", "banana"})
// Output:
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
}
func ExampleReqIdFromRelay() {
app.Run(ctx, []string{"nak", "req", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1", "nos.lol"})
// Output:
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
}
func ExampleReqWithFlagsAfter1() {
app.Run(ctx, []string{"nak", "req", "nos.lol", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1"})
// Output:
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
}
func ExampleReqWithFlagsAfter2() {
app.Run(ctx, []string{"nak", "req", "-e", "893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1", "nostr.mom", "--author", "2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6", "--limit", "1", "-k", "7"})
// Output:
// {"kind":7,"id":"9b4868b068ea34ae51092807586c4541b3569d9efc23862aea48ef13de275857","pubkey":"2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6","created_at":1720987327,"tags":[["e","893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1"],["p","1e978baae414eee990dba992871549ad4a099b9d6f7e71c8059b254ea024dddc"],["k","1"]],"content":"❤️","sig":"7eddd112c642ecdb031330dadc021790642b3c10ecc64158ba3ae63edd798b26afb9b5a3bba72835ce171719a724de1472f65c9b3339b6bead0ce2846f93dfc9"}
}
func ExampleReqWithFlagsAfter3() {
app.Run(ctx, []string{"nak", "req", "--limit", "1", "pyramid.fiatjaf.com", "-a", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-qp", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-e", "9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188"})
// Output:
// {"kind":1,"id":"101572c80ebdc963dab8440f6307387a3023b6d90f7e495d6c5ee1ef77045a67","pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","created_at":1720987305,"tags":[["e","ceacdc29fa7a0b51640b30d2424e188215460617db5ba5bb52d3fbf0094eebb3","","root"],["e","9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188","","reply"],["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"],["p","6b96c3eb36c6cd457d906bbaafe7b36cacfb8bcc4ab235be6eab3b71c6669251"]],"content":"Nope. I grew up playing in the woods. Never once saw a bear in the woods. If I did, I'd probably shiy my pants, then scream at it like I was a crazy person with my arms above my head to make me seem huge.","sig":"b098820b4a5635865cada9f9a5813be2bc6dd7180e16e590cf30e07916d8ed6ed98ab38b64f3bfba12d88d37335f229f7ef8c084bc48132e936c664a54d3e650"}
}
func ExampleNaturalTimestamps() {
app.Run(ctx, []string{"nak", "event", "-t", "plu=pla", "-e", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "--ts", "May 19 2018 03:37:19", "-c", "nn"})
// Output:
// {"kind":0,"id":"b10da0095f96aa2accd99fa3d93bf29a76f51d2594cf5a0a52f8e961aecd0b67","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1526711839,"tags":[["plu","pla"],["e","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"]],"content":"nn","sig":"988442c97064a041ba5e2bfbd64e84d3f819b2169e865511d9d53e74667949ff165325942acaa2ca233c8b529adedf12cf44088cf04081b56d098c5f4d52dd8f"}
}

View File

@@ -4,11 +4,11 @@ import (
"context"
"fmt"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk/hints"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip05"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/sdk/hints"
)
var fetch = &cli.Command{
@@ -27,16 +27,9 @@ var fetch = &cli.Command{
),
ArgsUsage: "[nip05_or_nip19_code]",
Action: func(ctx context.Context, c *cli.Command) error {
defer func() {
sys.Pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
relay.Close()
return true
})
}()
for code := range getStdinLinesOrArguments(c.Args()) {
filter := nostr.Filter{}
var authorHint string
var authorHint nostr.PubKey
relays := c.StringSlice("relay")
if nip05.IsValidIdentifier(code) {
@@ -63,15 +56,15 @@ var fetch = &cli.Command{
case "nevent":
v := value.(nostr.EventPointer)
filter.IDs = append(filter.IDs, v.ID)
if v.Author != "" {
if v.Author != nostr.ZeroPK {
authorHint = v.Author
}
relays = append(relays, v.Relays...)
case "note":
filter.IDs = append(filter.IDs, value.(string))
filter.IDs = append(filter.IDs, value.([32]byte))
case "naddr":
v := value.(nostr.EntityPointer)
filter.Kinds = []int{v.Kind}
filter.Kinds = []nostr.Kind{v.Kind}
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
filter.Authors = append(filter.Authors, v.PublicKey)
authorHint = v.PublicKey
@@ -82,7 +75,7 @@ var fetch = &cli.Command{
authorHint = v.PublicKey
relays = append(relays, v.Relays...)
case "npub":
v := value.(string)
v := value.(nostr.PubKey)
filter.Authors = append(filter.Authors, v)
authorHint = v
default:
@@ -90,7 +83,7 @@ var fetch = &cli.Command{
}
}
if authorHint != "" {
if authorHint != nostr.ZeroPK {
for _, url := range relays {
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
}
@@ -113,7 +106,7 @@ var fetch = &cli.Command{
continue
}
for ie := range sys.Pool.FetchMany(ctx, relays, filter) {
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{}) {
stdout(ie.Event)
}
}

136
flags.go
View File

@@ -6,14 +6,13 @@ import (
"strconv"
"time"
"github.com/urfave/cli/v3"
"fiatjaf.com/nostr"
"github.com/markusmobius/go-dateparser"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v3"
)
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
// wrap to satisfy golang's flag interface.
type naturalTimeValue struct {
timestamp *nostr.Timestamp
hasBeenSet bool
@@ -21,8 +20,6 @@ type naturalTimeValue struct {
var _ cli.ValueCreator[nostr.Timestamp, struct{}] = naturalTimeValue{}
// Below functions are to satisfy the ValueCreator interface
func (t naturalTimeValue) Create(val nostr.Timestamp, p *nostr.Timestamp, c struct{}) cli.Value {
*p = val
return &naturalTimeValue{
@@ -32,21 +29,12 @@ func (t naturalTimeValue) Create(val nostr.Timestamp, p *nostr.Timestamp, c stru
func (t naturalTimeValue) ToString(b nostr.Timestamp) string {
ts := b.Time()
if ts.IsZero() {
return ""
}
return fmt.Sprintf("%v", ts)
}
// Timestamp constructor(for internal testing only)
func newTimestamp(timestamp nostr.Timestamp) *naturalTimeValue {
return &naturalTimeValue{timestamp: &timestamp}
}
// Below functions are to satisfy the flag.Value interface
// Parses the string value to timestamp
func (t *naturalTimeValue) Set(value string) error {
var ts time.Time
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
@@ -75,21 +63,113 @@ func (t *naturalTimeValue) Set(value string) error {
return nil
}
// String returns a readable representation of this value (for usage defaults)
func (t *naturalTimeValue) String() string {
return fmt.Sprintf("%#v", t.timestamp)
}
// Value returns the timestamp value stored in the flag
func (t *naturalTimeValue) Value() *nostr.Timestamp {
return t.timestamp
}
// Get returns the flag structure
func (t *naturalTimeValue) Get() any {
return *t.timestamp
}
func (t *naturalTimeValue) String() string { return fmt.Sprintf("%#v", t.timestamp) }
func (t *naturalTimeValue) Value() *nostr.Timestamp { return t.timestamp }
func (t *naturalTimeValue) Get() any { return *t.timestamp }
func getNaturalDate(cmd *cli.Command, name string) nostr.Timestamp {
return cmd.Value(name).(nostr.Timestamp)
}
//
//
//
type (
PubKeyFlag = cli.FlagBase[nostr.PubKey, struct{}, pubkeyValue]
)
type pubkeyValue struct {
pubkey nostr.PubKey
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.PubKey, struct{}] = pubkeyValue{}
func (t pubkeyValue) Create(val nostr.PubKey, p *nostr.PubKey, c struct{}) cli.Value {
*p = val
return &pubkeyValue{
pubkey: val,
}
}
func (t pubkeyValue) ToString(b nostr.PubKey) string { return t.pubkey.String() }
func (t *pubkeyValue) Set(value string) error {
pk, err := nostr.PubKeyFromHex(value)
t.pubkey = pk
t.hasBeenSet = true
return err
}
func (t *pubkeyValue) String() string { return fmt.Sprintf("%#v", t.pubkey) }
func (t *pubkeyValue) Value() nostr.PubKey { return t.pubkey }
func (t *pubkeyValue) Get() any { return t.pubkey }
func getPubKey(cmd *cli.Command, name string) nostr.PubKey {
return cmd.Value(name).(nostr.PubKey)
}
//
//
//
type (
pubkeySlice = cli.SliceBase[nostr.PubKey, struct{}, pubkeyValue]
PubKeySliceFlag = cli.FlagBase[[]nostr.PubKey, struct{}, pubkeySlice]
)
func getPubKeySlice(cmd *cli.Command, name string) []nostr.PubKey {
return cmd.Value(name).([]nostr.PubKey)
}
//
//
//
type (
IDFlag = cli.FlagBase[nostr.ID, struct{}, idValue]
)
type idValue struct {
id nostr.ID
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.ID, struct{}] = idValue{}
func (t idValue) Create(val nostr.ID, p *nostr.ID, c struct{}) cli.Value {
*p = val
return &idValue{
id: val,
}
}
func (t idValue) ToString(b nostr.ID) string { return t.id.String() }
func (t *idValue) Set(value string) error {
pk, err := nostr.IDFromHex(value)
t.id = pk
t.hasBeenSet = true
return err
}
func (t *idValue) String() string { return fmt.Sprintf("%#v", t.id) }
func (t *idValue) Value() nostr.ID { return t.id }
func (t *idValue) Get() any { return t.id }
func getID(cmd *cli.Command, name string) nostr.ID {
return cmd.Value(name).(nostr.ID)
}
//
//
//
type (
idSlice = cli.SliceBase[nostr.ID, struct{}, idValue]
IDSliceFlag = cli.FlagBase[[]nostr.ID, struct{}, idSlice]
)
func getIDSlice(cmd *cli.Command, name string) []nostr.ID {
return cmd.Value(name).([]nostr.ID)
}

68
fs.go
View File

@@ -1,3 +1,5 @@
//go:build !windows
package main
import (
@@ -8,12 +10,12 @@ import (
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
"github.com/fiatjaf/nak/nostrfs"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/keyer"
"github.com/urfave/cli/v3"
)
@@ -22,18 +24,23 @@ var fsCmd = &cli.Command{
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: []cli.Flag{
&cli.StringFlag{
Flags: append(defaultKeyFlags,
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
Validator: func(pk string) error {
if nostr.IsValidPublicKey(pk) {
return nil
}
return fmt.Errorf("invalid public key '%s'", pk)
},
},
},
&cli.DurationFlag{
Name: "auto-publish-notes",
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
Value: time.Second * 30,
},
&cli.DurationFlag{
Name: "auto-publish-articles",
Usage: "delay after which edited articles will be auto-published.",
Value: time.Hour * 24 * 365 * 2,
DefaultText: "basically infinite",
},
),
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
mountpoint := c.Args().First()
@@ -41,11 +48,37 @@ var fsCmd = &cli.Command{
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
}
var kr nostr.User
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
kr = signer
} else {
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
}
apnt := c.Duration("auto-publish-notes")
if apnt < 0 {
apnt = time.Hour * 24 * 365 * 3
}
apat := c.Duration("auto-publish-articles")
if apat < 0 {
apat = time.Hour * 24 * 365 * 3
}
root := nostrfs.NewNostrRoot(
ctx,
context.WithValue(
context.WithValue(
ctx,
"log", log,
),
"logverbose", logverbose,
),
sys,
keyer.NewReadOnlyUser(c.String("pubkey")),
kr,
mountpoint,
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
)
// create the server
@@ -53,9 +86,10 @@ var fsCmd = &cli.Command{
timeout := time.Second * 120
server, err := fs.Mount(mountpoint, root, &fs.Options{
MountOptions: fuse.MountOptions{
Debug: isVerbose,
Name: "nak",
FsName: "nak",
Debug: isVerbose,
Name: "nak",
FsName: "nak",
RememberInodes: true,
},
AttrTimeout: &timeout,
EntryTimeout: &timeout,
@@ -64,7 +98,7 @@ var fsCmd = &cli.Command{
if err != nil {
return fmt.Errorf("mount failed: %w", err)
}
log("ok\n")
log("ok.\n")
// setup signal handling for clean unmount
ch := make(chan os.Signal, 1)

20
fs_windows.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build windows
package main
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
)
var fsCmd = &cli.Command{
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `doesn't work on Windows.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("this doesn't work on Windows.")
},
}

48
go.mod
View File

@@ -1,50 +1,55 @@
module github.com/fiatjaf/nak
go 1.23.3
toolchain go1.23.4
go 1.24.1
require (
fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.0-20250521022139-d3fb25441ab3
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.4
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.16.0
github.com/fiatjaf/eventstore v0.15.0
github.com/fiatjaf/khatru v0.16.0
github.com/hanwen/go-fuse/v2 v2.7.2
github.com/json-iterator/go v1.1.12
github.com/liamg/magic v0.0.1
github.com/mailru/easyjson v0.9.0
github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3
github.com/nbd-wtf/go-nostr v0.51.0
github.com/mattn/go-tty v0.0.7
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.0.0-beta1
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
golang.org/x/term v0.30.0
)
require (
fiatjaf.com/lib v0.2.0 // indirect
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/btcsuite/btcd v0.24.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/bytedance/sonic v1.13.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/logex v1.1.10 // indirect
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/dgraph-io/badger/v4 v4.5.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/pie/v2 v2.7.0 // indirect
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v24.12.23+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/graph-gophers/dataloader/v7 v7.1.0 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
@@ -58,7 +63,8 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/tetratelabs/wazero v1.8.0 // indirect
@@ -67,12 +73,16 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.58.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.21.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

142
go.sum
View File

@@ -1,7 +1,15 @@
fiatjaf.com/lib v0.2.0 h1:TgIJESbbND6GjOgGHxF5jsO6EMjuAxIzZHPo5DXYexs=
fiatjaf.com/lib v0.2.0/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
fiatjaf.com/nostr v0.0.0-20250521022139-d3fb25441ab3 h1:JRtme8g4UQ5KYlxI31wBa8YMWmAxvxdwtNn+PiI/XCs=
fiatjaf.com/nostr v0.0.0-20250521022139-d3fb25441ab3/go.mod h1:VPs38Fc8J1XAErV750CXAmMUqIq3XEX9VZVj/LuQzzM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg=
github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
@@ -33,11 +41,14 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g=
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
@@ -46,11 +57,13 @@ 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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -59,11 +72,15 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgraph-io/badger/v4 v4.5.0 h1:TeJE3I1pIWLBjYhIYCA1+uxrjWEoJXImFBMEBVSm16g=
github.com/dgraph-io/badger/v4 v4.5.0/go.mod h1:ysgYmIeG8dS/E8kwxT7xHyc7MkmwNYLRoYnFbr7387A=
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
github.com/dgraph-io/ristretto/v2 v2.1.0 h1:59LjpOJLNDULHh8MC4UaegN52lC4JnO2dITsie/Pa8I=
github.com/dgraph-io/ristretto/v2 v2.1.0/go.mod h1:uejeqfYXpUomfse0+lO+13ATz4TypQYLJZzBSAemuB4=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -73,35 +90,49 @@ github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3p
github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og=
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 h1:k7evIqJ2BtFn191DgY/b03N2bMYA/iQwzr4f/uHYn20=
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fiatjaf/eventstore v0.15.0 h1:5UXe0+vIb30/cYcOWipks8nR3g+X8W224TFy5yPzivk=
github.com/fiatjaf/eventstore v0.15.0/go.mod h1:KAsld5BhkmSck48aF11Txu8X+OGNmoabw4TlYVWqInc=
github.com/fiatjaf/khatru v0.16.0 h1:xgGwnnOqE3989wEWm7c/z6Y6g4X92BFe/Xp1UWQ3Zmc=
github.com/fiatjaf/khatru v0.16.0/go.mod h1:TLcMgPy3IAPh40VGYq6m+gxEMpDKHj+sumqcuvbSogc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v24.12.23+incompatible h1:ubBKR94NR4pXUCY/MUsRVzd9umNW7ht7EG9hHfS9FX8=
github.com/google/flatbuffers v24.12.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc=
github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q=
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
@@ -127,6 +158,8 @@ github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQe
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
@@ -140,6 +173,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=
github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -147,8 +182,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbd-wtf/go-nostr v0.51.0 h1:Z6gir3lQmlbQGYkccEPbvHlfCydMWXD6bIqukR4DZqU=
github.com/nbd-wtf/go-nostr v0.51.0/go.mod h1:9PcGOZ+e1VOaLvcK0peT4dbip+/eS+eTWXR3HuexQrA=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -162,8 +195,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.0 h1:i+cMcpEDY1BkNm7lPDkCtE4oElsYLn+EKF8kAu2vXT4=
github.com/puzpuzpuz/xsync/v3 v3.5.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
@@ -195,8 +229,8 @@ github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjc
github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
@@ -205,23 +239,40 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -231,24 +282,47 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -258,4 +332,8 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"errors"
"fmt"
"iter"
"math/rand"
@@ -10,24 +11,26 @@ import (
"net/textproto"
"net/url"
"os"
"runtime"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk"
"github.com/chzyer/readline"
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/sdk"
"github.com/mattn/go-tty"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
var sys *sdk.System
var (
hintsFilePath string
hintsFileExists bool
)
var json = jsoniter.ConfigFastest
const (
@@ -37,7 +40,7 @@ const (
var (
log = func(msg string, args ...any) { fmt.Fprintf(color.Error, msg, args...) }
logverbose = func(msg string, args ...any) {} // by default do nothing
stdout = fmt.Println
stdout = func(args ...any) { fmt.Fprintln(color.Output, args...) }
)
func isPiped() bool {
@@ -48,6 +51,7 @@ func isPiped() bool {
func getJsonsOrBlank() iter.Seq[string] {
var curr strings.Builder
var finalJsonErr error
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
@@ -57,8 +61,10 @@ func getJsonsOrBlank() iter.Seq[string] {
var dummy any
if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil {
finalJsonErr = err
return true
}
finalJsonErr = nil
if !yield(stdinEvent) {
return false
@@ -71,6 +77,10 @@ func getJsonsOrBlank() iter.Seq[string] {
if !hasStdin {
yield("{}")
}
if finalJsonErr != nil {
log(color.YellowString("stdin json parse error: %s", finalJsonErr))
}
}
}
@@ -148,67 +158,197 @@ func normalizeAndValidateRelayURLs(wsurls []string) error {
func connectToAllRelays(
ctx context.Context,
c *cli.Command,
relayUrls []string,
forcePreAuth bool,
opts ...nostr.PoolOption,
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 {
sys.Pool = nostr.NewSimplePool(context.Background(),
append(opts,
nostr.WithEventMiddleware(sys.TrackEventHints),
nostr.WithPenaltyBox(),
nostr.WithRelayOptions(
nostr.WithRequestHeader(http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}}),
),
)...,
)
relays := make([]*nostr.Relay, 0, len(relayUrls))
relayLoop:
// first pass to check if these are valid relay URLs
for _, url := range relayUrls {
log("connecting to %s... ", url)
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
if forcePreAuth {
log("waiting for auth challenge... ")
signer := opts[0].(nostr.WithAuthHandler)
time.Sleep(time.Millisecond * 200)
challengeWaitLoop:
for {
// beginhack
// here starts the biggest and ugliest hack of this codebase
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.GetFirst([]string{"challenge", ""})
if (*challengeTag)[1] == "" {
return fmt.Errorf("auth not received yet *****")
}
return signer(ctx, nostr.RelayEvent{Event: authEvent, Relay: relay})
}); err == nil {
// auth succeeded
break challengeWaitLoop
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second)
continue challengeWaitLoop
} else {
// it failed for some other reason, so skip this relay
log(err.Error() + "\n")
continue relayLoop
}
}
// endhack
}
}
relays = append(relays, relay)
log("ok.\n")
} else {
log(err.Error() + "\n")
if !nostr.IsValidRelayURL(nostr.NormalizeURL(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() {
// overcomplicated multiline rendering magic
lines := make([][][]byte, len(relayUrls))
flush := func() {
for _, line := range lines {
for _, part := range line {
os.Stderr.Write(part)
}
os.Stderr.Write([]byte{'\n'})
}
}
render := func() {
clearLines(len(lines))
flush()
}
flush()
wg := sync.WaitGroup{}
wg.Add(len(relayUrls))
for i, url := range relayUrls {
lines[i] = make([][]byte, 1, 2)
logthis := func(s string, args ...any) {
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
render()
}
colorizepreamble := func(c func(string, ...any) string) {
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
}
colorizepreamble(color.CyanString)
go func() {
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, colorizepreamble, logthis)
if relay != nil {
relays = append(relays, relay)
}
wg.Done()
}()
}
wg.Wait()
} else {
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(url))
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
if relay != nil {
relays = append(relays, relay)
}
log("\n")
}
}
return relays
}
func connectToSingleRelay(
ctx context.Context,
c *cli.Command,
url string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error),
colorizepreamble func(c func(string, ...any) string),
logthis func(s string, args ...any),
) *nostr.Relay {
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
if preAuthSigner != nil {
if colorizepreamble != nil {
colorizepreamble(color.YellowString)
}
logthis("waiting for auth challenge... ")
time.Sleep(time.Millisecond * 200)
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
}); err == nil {
// auth succeeded
goto preauthSuccess
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second)
continue
} else {
// it failed for some other reason, so skip this relay
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis(err.Error())
return nil
}
}
}
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis("failed to get an AUTH challenge in enough time.")
return nil
}
preauthSuccess:
if colorizepreamble != nil {
colorizepreamble(colors.successf)
}
logthis("ok.")
return relay
} else {
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
// if we're here that means we've failed to connect, this may be a huge message
// but we're likely to only be interested in the lowest level error (although we can leave space)
logthis(clampError(err, len(url)+12))
return nil
}
}
func clearLines(lineCount int) {
for i := 0; i < lineCount; i++ {
os.Stderr.Write([]byte("\033[0A\033[2K\r"))
}
}
func supportsDynamicMultilineMagic() bool {
if runtime.GOOS == "windows" {
return false
}
if !term.IsTerminal(0) {
return false
}
width, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil {
return false
}
if width < 110 {
return false
}
return true
}
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
defer func() {
if err != nil {
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
log("%s auth failed: %s", colors.errorf(cleanUrl), err)
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth required, but --auth flag not given")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
return kr.SignEvent(ctx, authEvent)
}
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
log(msg+"\n", args...)
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
@@ -230,20 +370,113 @@ func randString(n int) string {
return string(b)
}
func leftPadKey(k string) string {
return strings.Repeat("0", 64-len(k)) + k
func unwrapAll(err error) error {
low := err
for n := low; n != nil; n = errors.Unwrap(low) {
low = n
}
return low
}
func clampMessage(msg string, prefixAlreadyPrinted int) string {
termSize, _, _ := term.GetSize(int(os.Stderr.Fd()))
prf := "expected handshake response status code 101 but got "
if len(msg) > len(prf) && msg[0:len(prf)] == prf {
msg = "status " + msg[len(prf):]
}
if len(msg) > termSize-prefixAlreadyPrinted && prefixAlreadyPrinted+1 < termSize {
msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…"
}
return msg
}
func clampError(err error, prefixAlreadyPrinted int) string {
termSize, _, _ := term.GetSize(0)
msg := err.Error()
if len(msg) > termSize-prefixAlreadyPrinted {
err = unwrapAll(err)
msg = clampMessage(err.Error(), prefixAlreadyPrinted)
}
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()
if err != nil {
return false
}
defer tty.Close()
fmt.Fprintf(os.Stderr, color.YellowString(msg))
answer, err := tty.ReadString()
if err != nil {
return false
}
// print newline after password input
fmt.Fprintln(os.Stderr)
answer = strings.TrimSpace(string(answer))
return answer == "y" || answer == "yes"
} else {
config := &readline.Config{
Stdout: color.Error,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: false,
MaskRune: '*',
}
rl, err := readline.NewEx(config)
if err != nil {
return false
}
answer, err := rl.Readline()
if err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{
color.New(color.Reset).Print,
color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,
color.New(color.Bold, color.FgHiGreen).Sprintf,
}

View File

@@ -2,28 +2,30 @@ package main
import (
"context"
"encoding/hex"
"fmt"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip46"
"fiatjaf.com/nostr/nip49"
"github.com/chzyer/readline"
"github.com/fatih/color"
"github.com/mattn/go-tty"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/keyer"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46"
"github.com/nbd-wtf/go-nostr/nip49"
)
var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL, it is more secure to use the environment variable NOSTR_SECRET_KEY than this flag",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
DefaultText: "the key '1'",
Aliases: []string{"connect"},
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: nostr.KeyOne.Hex(),
HideDefault: true,
},
&cli.BoolFlag{
Name: "prompt-sec",
@@ -39,81 +41,76 @@ var defaultKeyFlags = []cli.Flag{
},
}
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, string, error) {
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, nostr.SecretKey, error) {
key, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return nil, "", err
return nil, nostr.SecretKey{}, err
}
var kr nostr.Keyer
if bunker != nil {
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
} else {
kr, err = keyer.NewPlainKeySigner(key)
kr = keyer.NewPlainKeySigner(key)
}
return kr, key, err
return kr, key, nil
}
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (string, *nip46.BunkerClient, error) {
var err error
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (nostr.SecretKey, *nip46.BunkerClient, error) {
sec := c.String("sec")
if strings.HasPrefix(sec, "bunker://") {
// it's a bunker
bunkerURL := sec
clientKey := c.String("connect-as")
if clientKey != "" {
clientKey = strings.Repeat("0", 64-len(clientKey)) + clientKey
clientKeyHex := c.String("connect-as")
var clientKey nostr.SecretKey
if clientKeyHex != "" {
var err error
clientKey, err = nostr.SecretKeyFromHex(clientKeyHex)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("bunker client key '%s' is invalid: %w", clientKeyHex, err)
}
} else {
clientKey = nostr.GeneratePrivateKey()
clientKey = nostr.Generate()
}
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, nil, func(s string) {
log(color.CyanString("[nip46]: open the following URL: %s"), s)
})
return "", bunker, err
}
// take private from flags, environment variable or default to 1
if sec == "" {
if key, ok := os.LookupEnv("NOSTR_SECRET_KEY"); ok {
sec = key
} else {
sec = "0000000000000000000000000000000000000000000000000000000000000001"
}
return nostr.SecretKey{}, bunker, err
}
if c.Bool("prompt-sec") {
if isPiped() {
return "", nil, fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
}
var err error
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
if err != nil {
return "", nil, fmt.Errorf("failed to get secret key: %w", err)
return nostr.SecretKey{}, nil, fmt.Errorf("failed to get secret key: %w", err)
}
}
if strings.HasPrefix(sec, "ncryptsec1") {
sec, err = promptDecrypt(sec)
sk, err := promptDecrypt(sec)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt: %w", err)
return nostr.SecretKey{}, nil, fmt.Errorf("failed to decrypt: %w", err)
}
} else if bsec, err := hex.DecodeString(leftPadKey(sec)); err == nil {
sec = hex.EncodeToString(bsec)
} else if prefix, hexvalue, err := nip19.Decode(sec); err != nil {
return "", nil, fmt.Errorf("invalid nsec: %w", err)
} else if prefix == "nsec" {
sec = hexvalue.(string)
return sk, nil, nil
}
if ok := nostr.IsValid32ByteHex(sec); !ok {
return "", nil, fmt.Errorf("invalid secret key")
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
return ski.(nostr.SecretKey), nil, nil
}
return sec, nil, nil
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("invalid secret key: %w", err)
}
return sk, nil, nil
}
func promptDecrypt(ncryptsec string) (string, error) {
func promptDecrypt(ncryptsec string) (nostr.SecretKey, error) {
for i := 1; i < 4; i++ {
var attemptStr string
if i > 1 {
@@ -121,7 +118,7 @@ func promptDecrypt(ncryptsec string) (string, error) {
}
password, err := askPassword("type the password to decrypt your secret key"+attemptStr+": ", nil)
if err != nil {
return "", err
return nostr.SecretKey{}, err
}
sec, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
@@ -129,33 +126,62 @@ func promptDecrypt(ncryptsec string) (string, error) {
}
return sec, nil
}
return "", fmt.Errorf("couldn't decrypt private key")
return nostr.SecretKey{}, fmt.Errorf("couldn't decrypt private key")
}
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
config := &readline.Config{
Stdout: color.Error,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: true,
MaskRune: '*',
}
if isPiped() {
// use TTY method when stdin is piped
tty, err := tty.Open()
if err != nil {
return "", fmt.Errorf("can't prompt for a secret key when processing data from a pipe on this system (failed to open /dev/tty: %w), try again without --prompt-sec or provide the key via --sec or NOSTR_SECRET_KEY environment variable", err)
}
defer tty.Close()
for {
// print the prompt to stderr so it's visible to the user
fmt.Fprintf(os.Stderr, color.YellowString(msg))
rl, err := readline.NewEx(config)
if err != nil {
return "", err
}
// read password from TTY with masking
password, err := tty.ReadPassword()
if err != nil {
return "", err
}
for {
answer, err := rl.Readline()
// print newline after password input
fmt.Fprintln(os.Stderr)
answer := strings.TrimSpace(string(password))
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, nil
}
} else {
// use normal readline method when stdin is not piped
config := &readline.Config{
Stdout: os.Stderr,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: true,
MaskRune: '*',
}
rl, err := readline.NewEx(config)
if err != nil {
return "", err
}
answer = strings.TrimSpace(answer)
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
for {
answer, err := rl.Readline()
if err != nil {
return "", err
}
answer = strings.TrimSpace(answer)
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, err
}
return answer, err
}
}

5
justfile Normal file
View File

@@ -0,0 +1,5 @@
test:
#!/usr/bin/env fish
for test in (go test -list .)
go test -run=$test -v
end

45
key.go
View File

@@ -6,13 +6,13 @@ import (
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip49"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip49"
)
var key = &cli.Command{
@@ -35,8 +35,8 @@ var generate = &cli.Command{
Description: ``,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
sec := nostr.GeneratePrivateKey()
stdout(sec)
sec := nostr.Generate()
stdout(sec.Hex())
return nil
},
}
@@ -54,9 +54,8 @@ var public = &cli.Command{
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for sec := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
b, _ := hex.DecodeString(sec)
_, pk := btcec.PrivKeyFromBytes(b)
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
_, pk := btcec.PrivKeyFromBytes(sk[:])
if c.Bool("with-parity") {
stdout(hex.EncodeToString(pk.SerializeCompressed()))
@@ -123,30 +122,30 @@ var decryptKey = &cli.Command{
if password == "" {
return fmt.Errorf("no password given")
}
sec, err := nip49.Decrypt(ncryptsec, password)
sk, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
return fmt.Errorf("failed to decrypt: %s", err)
}
stdout(sec)
stdout(sk.Hex())
return nil
case 1:
if arg := c.Args().Get(0); strings.HasPrefix(arg, "ncryptsec1") {
ncryptsec = arg
if res, err := promptDecrypt(ncryptsec); err != nil {
if sk, err := promptDecrypt(ncryptsec); err != nil {
return err
} else {
stdout(res)
stdout(sk.Hex())
return nil
}
} else {
password = c.Args().Get(0)
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
sec, err := nip49.Decrypt(ncryptsec, password)
sk, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
continue
}
stdout(sec)
stdout(sk.Hex())
}
return nil
}
@@ -264,27 +263,31 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
},
}
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan string {
ch := make(chan string)
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan nostr.SecretKey {
ch := make(chan nostr.SecretKey)
go func() {
for sec := range getStdinLinesOrArgumentsFromSlice(keys) {
if sec == "" {
continue
}
var sk nostr.SecretKey
if strings.HasPrefix(sec, "nsec1") {
_, data, err := nip19.Decode(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid nsec code: %s", err)
continue
}
sec = data.(string)
sk = data.(nostr.SecretKey)
}
sec = leftPadKey(sec)
if !nostr.IsValid32ByteHex(sec) {
ctx = lineProcessingError(ctx, "invalid hex key")
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
ch <- sec
ch <- sk
}
close(ch)
}()

88
main.go
View File

@@ -2,14 +2,14 @@ package main
import (
"context"
"fmt"
"net/http"
"net/textproto"
"os"
"path/filepath"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/sdk"
"github.com/nbd-wtf/go-nostr/sdk/hints/memoryh"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -43,8 +43,8 @@ var app = &cli.Command{
wallet,
mcpServer,
curl,
dvm,
fsCmd,
publish,
},
Version: version,
Flags: []cli.Flag{
@@ -61,7 +61,7 @@ var app = &cli.Command{
if q >= 1 {
log = func(msg string, args ...any) {}
if q >= 2 {
stdout = func(_ ...any) (int, error) { return 0, nil }
stdout = func(_ ...any) {}
}
}
return nil
@@ -82,72 +82,40 @@ var app = &cli.Command{
},
},
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
configPath := c.String("config-path")
if configPath == "" {
if home, err := os.UserHomeDir(); err == nil {
configPath = filepath.Join(home, ".config/nak")
}
}
if configPath != "" {
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
}
if hintsFilePath != "" {
if _, err := os.Stat(hintsFilePath); !os.IsNotExist(err) {
hintsFileExists = true
}
}
if hintsFilePath != "" {
if data, err := os.ReadFile(hintsFilePath); err == nil {
hintsdb := memoryh.NewHintDB()
if err := json.Unmarshal(data, &hintsdb); err == nil {
sys = sdk.NewSystem(
sdk.WithHintsDB(hintsdb),
)
goto systemOperational
}
}
}
sys = sdk.NewSystem()
systemOperational:
sys.Pool = nostr.NewSimplePool(context.Background(),
nostr.WithAuthorKindQueryMiddleware(sys.TrackQueryAttempts),
nostr.WithEventMiddleware(sys.TrackEventHints),
nostr.WithRelayOptions(
nostr.WithRequestHeader(http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}}),
),
)
if err := initializeOutboxHintsDB(c, sys); err != nil {
return ctx, fmt.Errorf("failed to initialized outbox hints: %w", err)
}
sys.Pool = nostr.NewPool(nostr.PoolOptions{
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
EventMiddleware: sys.TrackEventHints,
RelayOptions: nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
},
})
return ctx, nil
},
After: func(ctx context.Context, c *cli.Command) error {
// save hints database on exit
if hintsFileExists {
data, err := json.Marshal(sys.Hints)
if err != nil {
return err
}
return os.WriteFile(hintsFilePath, data, 0644)
}
}
return nil
},
func init() {
cli.VersionFlag = &cli.BoolFlag{
Name: "version",
Usage: "prints the version",
}
}
func main() {
defer colors.reset()
cli.VersionFlag = &cli.BoolFlag{
Name: "version",
Usage: "prints the version",
}
// a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil {
stdout(err)
if err != nil {
log(color.YellowString(err.Error()) + "\n")
}
colors.reset()
os.Exit(1)
}
@@ -155,7 +123,9 @@ func main() {
}
if err := app.Run(context.Background(), os.Args); err != nil {
stdout(err)
if err != nil {
log(color.YellowString(err.Error()) + "\n")
}
colors.reset()
os.Exit(1)
}

206
mcp.go
View File

@@ -3,14 +3,13 @@ package main
import (
"context"
"fmt"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/sdk"
"github.com/urfave/cli/v3"
)
@@ -19,43 +18,30 @@ var mcpServer = &cli.Command{
Usage: "pander to the AI gods",
Description: ``,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{},
Flags: append(
defaultKeyFlags,
),
Action: func(ctx context.Context, c *cli.Command) error {
s := server.NewMCPServer(
"nak",
version,
)
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("relay",
mcp.Description("Relay to publish the note to"),
),
mcp.WithString("content",
mcp.Required(),
mcp.Description("Arbitrary string to be published"),
),
mcp.WithString("mention",
mcp.Required(),
mcp.Description("Nostr user's public key to be mentioned"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content, _ := request.Params.Arguments["content"].(string)
mention, _ := request.Params.Arguments["mention"].(string)
relayI, ok := request.Params.Arguments["relay"]
var relay string
if ok {
relay, _ = relayI.(string)
}
mcp.WithString("content", mcp.Description("Arbitrary string to be published"), mcp.Required()),
mcp.WithString("relay", mcp.Description("Relay to publish the note to")),
mcp.WithString("mention", mcp.Description("Nostr user's public key to be mentioned")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content := required[string](r, "content")
mention, _ := optional[string](r, "mention")
relay, _ := optional[string](r, "relay")
if mention != "" && !nostr.IsValidPublicKey(mention) {
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
}
sk := os.Getenv("NOSTR_SECRET_KEY")
if sk == "" {
sk = "0000000000000000000000000000000000000000000000000000000000000001"
}
var relays []string
evt := nostr.Event{
@@ -66,12 +52,19 @@ var mcpServer = &cli.Command{
}
if mention != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"p", mention})
pk, err := nostr.PubKeyFromHex(mention)
if err != nil {
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
}
evt.Tags = append(evt.Tags, nostr.Tag{"p", pk.Hex()})
// their inbox relays
relays = sys.FetchInboxRelays(ctx, mention, 3)
relays = sys.FetchInboxRelays(ctx, pk, 3)
}
evt.Sign(sk)
if err := keyer.SignEvent(ctx, &evt); err != nil {
return mcp.NewToolResultError("it was impossible to sign the event, so we can't proceed to publishwith publishing it."), nil
}
// our write relays
relays = append(relays, sys.FetchOutboxRelays(ctx, evt.PubKey, 3)...)
@@ -81,7 +74,9 @@ var mcpServer = &cli.Command{
}
// extra relay specified
relays = append(relays, relay)
if relay != "" {
relays = append(relays, relay)
}
result := strings.Builder{}
result.WriteString(
@@ -111,12 +106,9 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("resolve_nostr_uri",
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
mcp.WithString("uri",
mcp.Required(),
mcp.Description("URI to be resolved"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri, _ := request.Params.Arguments["uri"].(string)
mcp.WithString("uri", mcp.Description("URI to be resolved"), mcp.Required()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri := required[string](r, "uri")
if strings.HasPrefix(uri, "nostr:") {
uri = uri[6:]
}
@@ -128,7 +120,7 @@ var mcpServer = &cli.Command{
switch prefix {
case "npub":
pm := sys.FetchProfileMetadata(ctx, data.(string))
pm := sys.FetchProfileMetadata(ctx, data.(nostr.PubKey))
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
pm.ShortName(), pm.PubKey),
@@ -159,77 +151,80 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("search_profile",
mcp.WithDescription("Search for the public key of a Nostr user given their name"),
mcp.WithString("name",
mcp.Required(),
mcp.Description("Name to be searched"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, _ := request.Params.Arguments["name"].(string)
re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}})
if re == nil {
return mcp.NewToolResultError("couldn't find anyone with that name"), nil
mcp.WithString("name", mcp.Description("Name to be searched"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("How many results to return")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
return mcp.NewToolResultText(re.PubKey), nil
res := strings.Builder{}
res.WriteString("Search results: ")
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine"}, filter, nostr.SubscriptionOptions{}) {
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
}
return mcp.NewToolResultText(res.String()), nil
})
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"),
mcp.WithString("pubkey",
mcp.Required(),
mcp.Description("Public key of Nostr user we want to know the relay from where to read"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pubkey, _ := request.Params.Arguments["pubkey"].(string)
mcp.WithString("pubkey", mcp.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pubkey, err := nostr.PubKeyFromHex(required[string](r, "pubkey"))
if err != nil {
return mcp.NewToolResultError("the pubkey given isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
}
res := sys.FetchOutboxRelays(ctx, pubkey, 1)
return mcp.NewToolResultText(res[0]), nil
})
s.AddTool(mcp.NewTool("read_events_from_relay",
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
mcp.WithNumber("kind",
mcp.Required(),
mcp.Description("event kind number to include in the 'kinds' field"),
),
mcp.WithString("pubkey",
mcp.Description("pubkey to include in the 'authors' field"),
),
mcp.WithNumber("limit",
mcp.Required(),
mcp.Description("maximum number of events to query"),
),
mcp.WithString("relay",
mcp.Required(),
mcp.Description("relay URL to send the query to"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relay, _ := request.Params.Arguments["relay"].(string)
limit, _ := request.Params.Arguments["limit"].(int)
kind, _ := request.Params.Arguments["kind"].(int)
pubkeyI, ok := request.Params.Arguments["pubkey"]
var pubkey string
if ok {
pubkey, _ = pubkeyI.(string)
}
if pubkey != "" && !nostr.IsValidPublicKey(pubkey) {
return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
}
mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
mcp.WithString("pubkey", mcp.Description("pubkey to include in the 'authors' field, if this is not given we will read any events from this relay")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relay := required[string](r, "relay")
kind := int(required[float64](r, "kind"))
limit := int(required[float64](r, "limit"))
pubkey, hasPubKey := optional[string](r, "pubkey")
filter := nostr.Filter{
Limit: limit,
Kinds: []int{kind},
}
if pubkey != "" {
filter.Authors = []string{pubkey}
Kinds: []nostr.Kind{nostr.Kind(kind)},
}
events := sys.Pool.FetchMany(ctx, []string{relay}, filter)
if hasPubKey {
if pk, err := nostr.PubKeyFromHex(pubkey); err != nil {
return mcp.NewToolResultError("the pubkey given isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
} else {
filter.Authors = append(filter.Authors, pk)
}
}
events := sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{})
result := strings.Builder{}
for ie := range events {
result.WriteString("author public key: ")
result.WriteString(ie.PubKey)
result.WriteString(ie.PubKey.Hex())
result.WriteString("content: '")
result.WriteString(ie.Content)
result.WriteString("'")
@@ -242,3 +237,28 @@ var mcpServer = &cli.Command{
return server.ServeStdio(s)
},
}
func required[T comparable](r mcp.CallToolRequest, p string) T {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero
}
if r.Params.Arguments[p].(T) == zero {
return zero
}
return r.Params.Arguments[p].(T)
}
func optional[T any](r mcp.CallToolRequest, p string) (T, bool) {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero, false
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero, false
}
return r.Params.Arguments[p].(T), true
}

View File

@@ -9,39 +9,39 @@ import (
"strconv"
"strings"
"fiatjaf.com/nostr"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/nbd-wtf/go-nostr"
)
func getMusigAggregatedKey(_ context.Context, keys []string) (string, error) {
func getMusigAggregatedKey(_ context.Context, keys []string) (nostr.PubKey, error) {
knownSigners := make([]*btcec.PublicKey, len(keys))
for i, spk := range keys {
bpk, err := hex.DecodeString(spk)
if err != nil {
return "", fmt.Errorf("'%s' is invalid hex: %w", spk, err)
return nostr.ZeroPK, fmt.Errorf("'%s' is invalid hex: %w", spk, err)
}
if len(bpk) == 32 {
return "", fmt.Errorf("'%s' is missing the leading parity byte", spk)
return nostr.ZeroPK, fmt.Errorf("'%s' is missing the leading parity byte", spk)
}
pk, err := btcec.ParsePubKey(bpk)
if err != nil {
return "", fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
return nostr.ZeroPK, fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
}
knownSigners[i] = pk
}
aggpk, _, _, err := musig2.AggregateKeys(knownSigners, true)
if err != nil {
return "", fmt.Errorf("aggregation failed: %w", err)
return nostr.ZeroPK, fmt.Errorf("aggregation failed: %w", err)
}
return hex.EncodeToString(aggpk.FinalKey.SerializeCompressed()[1:]), nil
return nostr.PubKey(aggpk.FinalKey.SerializeCompressed()[1:]), nil
}
func performMusig(
_ context.Context,
sec string,
sec nostr.SecretKey,
evt *nostr.Event,
numSigners int,
keys []string,
@@ -50,11 +50,7 @@ func performMusig(
partialSigs []string,
) (signed bool, err error) {
// preprocess data received
secb, err := hex.DecodeString(sec)
if err != nil {
return false, err
}
seck, pubk := btcec.PrivKeyFromBytes(secb)
seck, pubk := btcec.PrivKeyFromBytes(sec[:])
knownSigners := make([]*btcec.PublicKey, 0, numSigners)
includesUs := false
@@ -146,7 +142,7 @@ func performMusig(
if comb, err := mctx.CombinedKey(); err != nil {
return false, fmt.Errorf("failed to combine keys (after %d signers): %w", len(knownSigners), err)
} else {
evt.PubKey = hex.EncodeToString(comb.SerializeCompressed()[1:])
evt.PubKey = nostr.PubKey(comb.SerializeCompressed()[1:])
evt.ID = evt.GetID()
log("combined key: %x\n\n", comb.SerializeCompressed())
}
@@ -200,11 +196,7 @@ func performMusig(
// signing phase
// we always have to sign, so let's do this
id := evt.GetID()
hash, _ := hex.DecodeString(id)
var msg32 [32]byte
copy(msg32[:], hash)
partialSig, err := session.Sign(msg32) // this will already include our sig in the bundle
partialSig, err := session.Sign(evt.GetID()) // this will already include our sig in the bundle
if err != nil {
return false, fmt.Errorf("failed to produce partial signature: %w", err)
}
@@ -225,7 +217,7 @@ func performMusig(
}
// we have the signature
evt.Sig = hex.EncodeToString(session.FinalSig().Serialize())
evt.Sig = [64]byte(session.FinalSig().Serialize())
return true, nil
}
@@ -258,7 +250,7 @@ func eventToCliArgs(evt *nostr.Event) string {
b.Grow(100)
b.WriteString("-k ")
b.WriteString(strconv.Itoa(evt.Kind))
b.WriteString(strconv.Itoa(int(evt.Kind)))
b.WriteString(" -ts ")
b.WriteString(strconv.FormatInt(int64(evt.CreatedAt), 10))
@@ -269,7 +261,7 @@ func eventToCliArgs(evt *nostr.Event) string {
for _, tag := range evt.Tags {
b.WriteString(" -t '")
b.WriteString(tag.Key())
b.WriteString(tag[0])
if len(tag) > 1 {
b.WriteString("=")
b.WriteString(tag[1])

View File

@@ -7,7 +7,7 @@ import (
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
"fiatjaf.com/nostr"
)
type AsyncFile struct {

View File

@@ -0,0 +1,50 @@
package nostrfs
import (
"context"
"syscall"
"unsafe"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type DeterministicFile struct {
fs.Inode
get func() (ctime, mtime uint64, data string)
}
var (
_ = (fs.NodeOpener)((*DeterministicFile)(nil))
_ = (fs.NodeReader)((*DeterministicFile)(nil))
_ = (fs.NodeGetattrer)((*DeterministicFile)(nil))
)
func (r *NostrRoot) NewDeterministicFile(get func() (ctime, mtime uint64, data string)) *DeterministicFile {
return &DeterministicFile{
get: get,
}
}
func (f *DeterministicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *DeterministicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
var content string
out.Mode = 0444
out.Ctime, out.Mtime, content = f.get()
out.Size = uint64(len(content))
return fs.OK
}
func (f *DeterministicFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
_, _, content := f.get()
data := unsafe.Slice(unsafe.StringData(content), len(content))
end := int(off) + len(dest)
if end > len(data) {
end = len(data)
}
return fuse.ReadResultData(data[off:end]), fs.OK
}

View File

@@ -9,155 +9,191 @@ import (
"net/http"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"fiatjaf.com/lib/debouncer"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip27"
"fiatjaf.com/nostr/nip73"
"fiatjaf.com/nostr/nip92"
sdk "fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip27"
"github.com/nbd-wtf/go-nostr/nip92"
sdk "github.com/nbd-wtf/go-nostr/sdk"
)
type EntityDir struct {
fs.Inode
ctx context.Context
wd string
evt *nostr.Event
root *NostrRoot
publisher *debouncer.Debouncer
event *nostr.Event
updating struct {
title string
content string
publishedAt uint64
}
}
var _ = (fs.NodeGetattrer)((*EntityDir)(nil))
var (
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
_ = (fs.NodeCreater)((*EntityDir)(nil))
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
)
func (e *EntityDir) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
publishedAt := uint64(e.evt.CreatedAt)
out.Ctime = publishedAt
if tag := e.evt.Tags.Find("published_at"); tag != nil {
publishedAt, _ = strconv.ParseUint(tag[1], 10, 64)
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Ctime = uint64(e.event.CreatedAt)
if e.updating.publishedAt != 0 {
out.Mtime = e.updating.publishedAt
} else {
out.Mtime = e.PublishedAt()
}
out.Mtime = publishedAt
return fs.OK
}
func FetchAndCreateEntityDir(
ctx context.Context,
parent fs.InodeEmbedder,
wd string,
extension string,
sys *sdk.System,
pointer nostr.EntityPointer,
) (*fs.Inode, error) {
event, _, err := sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
func (e *EntityDir) Create(
_ context.Context,
name string,
flags uint32,
mode uint32,
out *fuse.EntryOut,
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
if name == "publish" && e.publisher.IsRunning() {
// this causes the publish process to be triggered faster
log := e.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing now!\n")
e.publisher.Flush()
return nil, nil, 0, syscall.ENOTDIR
}
return CreateEntityDir(ctx, parent, wd, extension, event), nil
return nil, nil, 0, syscall.ENOTSUP
}
func CreateEntityDir(
ctx context.Context,
parent fs.InodeEmbedder,
wd string,
extension string,
event *nostr.Event,
) *fs.Inode {
h := parent.EmbeddedInode().NewPersistentInode(
ctx,
&EntityDir{ctx: ctx, wd: wd, evt: event},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: hexToUint64(event.ID)},
)
var publishedAt uint64
if tag := event.Tags.Find("published_at"); tag != nil {
publishedAt, _ = strconv.ParseUint(tag[1], 10, 64)
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
switch name {
case "content" + kindToExtension(e.event.Kind):
e.updating.content = e.event.Content
return syscall.ENOTDIR
case "title":
e.updating.title = e.Title()
return syscall.ENOTDIR
default:
return syscall.EINTR
}
}
npub, _ := nip19.EncodePublicKey(event.PubKey)
h.AddChild("@author", h.NewPersistentInode(
ctx,
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
e.updating.publishedAt = in.Mtime
return fs.OK
}
func (e *EntityDir) OnAdd(_ context.Context) {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
e.AddChild("@author", e.NewPersistentInode(
e.root.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + npub),
Data: []byte(e.root.wd + "/" + nip19.EncodeNpub(e.event.PubKey)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
eventj, _ := json.MarshalIndent(event, "", " ")
h.AddChild("event.json", h.NewPersistentInode(
ctx,
&fs.MemRegularFile{
Data: eventj,
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(publishedAt),
Size: uint64(len(event.Content)),
e.AddChild("event.json", e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
eventj, _ := json.MarshalIndent(e.event, "", " ")
return uint64(e.event.CreatedAt),
uint64(e.event.CreatedAt),
unsafe.String(unsafe.SliceData(eventj), len(eventj))
},
},
fs.StableAttr{},
), true)
h.AddChild("identifier", h.NewPersistentInode(
ctx,
e.AddChild("identifier", e.NewPersistentInode(
e.root.ctx,
&fs.MemRegularFile{
Data: []byte(event.Tags.GetD()),
Data: []byte(e.event.Tags.GetD()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(publishedAt),
Size: uint64(len(event.Tags.GetD())),
Ctime: uint64(e.event.CreatedAt),
Mtime: uint64(e.event.CreatedAt),
Size: uint64(len(e.event.Tags.GetD())),
},
},
fs.StableAttr{},
), true)
if tag := event.Tags.Find("title"); tag != nil {
h.AddChild("title", h.NewPersistentInode(
ctx,
&fs.MemRegularFile{
Data: []byte(tag[1]),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(publishedAt),
Size: uint64(len(tag[1])),
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
// read-only
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
},
},
fs.StableAttr{},
), true)
}
h.AddChild("content"+extension, h.NewPersistentInode(
ctx,
&fs.MemRegularFile{
Data: []byte(event.Content),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(publishedAt),
Size: uint64(len(event.Content)),
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
},
},
},
fs.StableAttr{},
), true)
fs.StableAttr{},
), true)
} else {
// writeable
e.updating.title = e.Title()
e.updating.publishedAt = e.PublishedAt()
e.updating.content = e.event.Content
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
log("title updated")
e.updating.title = strings.TrimSpace(s)
e.handleWrite()
}),
fs.StableAttr{},
), true)
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
log("content updated")
e.updating.content = strings.TrimSpace(s)
e.handleWrite()
}),
fs.StableAttr{},
), true)
}
var refsdir *fs.Inode
i := 0
for ref := range nip27.ParseReferences(*event) {
for ref := range nip27.Parse(e.event.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
i++
if refsdir == nil {
refsdir = h.NewPersistentInode(ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("references", refsdir, true)
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
e.root.AddChild("references", refsdir, true)
}
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
ctx,
e.root.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + nip19.EncodePointer(ref.Pointer)),
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
@@ -167,26 +203,29 @@ func CreateEntityDir(
addImage := func(url string) {
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = h.NewPersistentInode(ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("images", imagesdir, true)
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
e.AddChild("images", imagesdir, true)
}
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
ctx,
e.root.ctx,
&AsyncFile{
ctx: ctx,
ctx: e.root.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(ctx, time.Second*20)
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
w := &bytes.Buffer{}
@@ -198,7 +237,7 @@ func CreateEntityDir(
), true)
}
images := nip92.ParseTags(event.Tags)
images := nip92.ParseTags(e.event.Tags)
for _, imeta := range images {
if imeta.URL == "" {
continue
@@ -206,9 +245,164 @@ func CreateEntityDir(
addImage(imeta.URL)
}
if tag := event.Tags.Find("image"); tag != nil {
if tag := e.event.Tags.Find("image"); tag != nil {
addImage(tag[1])
}
return h
}
func (e *EntityDir) IsNew() bool {
return e.event.CreatedAt == 0
}
func (e *EntityDir) PublishedAt() uint64 {
if tag := e.event.Tags.Find("published_at"); tag != nil {
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
return publishedAt
}
return uint64(e.event.CreatedAt)
}
func (e *EntityDir) Title() string {
if tag := e.event.Tags.Find("title"); tag != nil {
return tag[1]
}
return ""
}
func (e *EntityDir) handleWrite() {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
if e.publisher.IsRunning() {
log(", timer reset")
}
log(", publishing the ")
if e.IsNew() {
log("new")
} else {
log("updated")
}
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
} else {
log(".\n")
}
if !e.publisher.IsRunning() {
log("- `touch publish` to publish immediately\n")
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
}
e.publisher.Call(func() {
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
log("not modified, publish canceled.\n")
return
}
evt := nostr.Event{
Kind: e.event.Kind,
Content: e.updating.content,
Tags: make(nostr.Tags, len(e.event.Tags)),
CreatedAt: nostr.Now(),
}
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
if e.updating.title != "" {
if titleTag := evt.Tags.Find("title"); titleTag != nil {
titleTag[1] = e.updating.title
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
}
}
// "published_at" tag
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
if publishedAtStr != "0" {
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
publishedAtTag[1] = publishedAtStr
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
}
}
// add "p" tags from people mentioned and "q" tags from events mentioned
for ref := range nip27.Parse(evt.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
tag := ref.Pointer.AsTag()
key := tag[0]
val := tag[1]
if key == "e" || key == "a" {
key = "q"
}
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
evt.Tags = append(evt.Tags, tag)
}
}
// sign and publish
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
log("failed to sign: '%s'.\n", err)
return
}
logverbose("%s\n", evt)
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey)
if len(relays) == 0 {
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
}
log("publishing to %d relays... ", len(relays))
success := false
first := true
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
success = true
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n")
if success {
e.event = &evt
log("event updated locally.\n")
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
} else {
log("failed.\n")
}
})
}
func (r *NostrRoot) FetchAndCreateEntityDir(
parent fs.InodeEmbedder,
extension string,
pointer nostr.EntityPointer,
) (*fs.Inode, error) {
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
return r.CreateEntityDir(parent, event), nil
}
func (r *NostrRoot) CreateEntityDir(
parent fs.InodeEmbedder,
event *nostr.Event,
) *fs.Inode {
return parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
fs.StableAttr{Mode: syscall.S_IFDIR},
)
}

View File

@@ -3,6 +3,7 @@ package nostrfs
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
@@ -11,15 +12,16 @@ import (
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip10"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip22"
"fiatjaf.com/nostr/nip27"
"fiatjaf.com/nostr/nip73"
"fiatjaf.com/nostr/nip92"
sdk "fiatjaf.com/nostr/sdk"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip10"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip22"
"github.com/nbd-wtf/go-nostr/nip27"
"github.com/nbd-wtf/go-nostr/nip92"
sdk "github.com/nbd-wtf/go-nostr/sdk"
)
type EventDir struct {
@@ -31,52 +33,46 @@ type EventDir struct {
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
func (e *EventDir) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mtime = uint64(e.evt.CreatedAt)
return fs.OK
}
func FetchAndCreateEventDir(
ctx context.Context,
func (r *NostrRoot) FetchAndCreateEventDir(
parent fs.InodeEmbedder,
wd string,
sys *sdk.System,
pointer nostr.EventPointer,
) (*fs.Inode, error) {
event, _, err := sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
return CreateEventDir(ctx, parent, wd, event), nil
return r.CreateEventDir(parent, event), nil
}
func CreateEventDir(
ctx context.Context,
func (r *NostrRoot) CreateEventDir(
parent fs.InodeEmbedder,
wd string,
event *nostr.Event,
) *fs.Inode {
h := parent.EmbeddedInode().NewPersistentInode(
ctx,
&EventDir{ctx: ctx, wd: wd, evt: event},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: hexToUint64(event.ID)},
r.ctx,
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
)
npub, _ := nip19.EncodePublicKey(event.PubKey)
h.AddChild("@author", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + npub),
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
eventj, _ := json.MarshalIndent(event, "", " ")
h.AddChild("event.json", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemRegularFile{
Data: eventj,
Attr: fuse.Attr{
@@ -90,9 +86,9 @@ func CreateEventDir(
), true)
h.AddChild("id", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.ID),
Data: []byte(event.ID.Hex()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
@@ -104,7 +100,7 @@ func CreateEventDir(
), true)
h.AddChild("content.txt", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.Content),
Attr: fuse.Attr{
@@ -119,16 +115,20 @@ func CreateEventDir(
var refsdir *fs.Inode
i := 0
for ref := range nip27.ParseReferences(*event) {
for ref := range nip27.Parse(event.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
i++
if refsdir == nil {
refsdir = h.NewPersistentInode(ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("references", refsdir, true)
}
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
ctx,
r.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + nip19.EncodePointer(ref.Pointer)),
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
@@ -142,15 +142,15 @@ func CreateEventDir(
}
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = h.NewPersistentInode(ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("images", imagesdir, true)
}
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
ctx,
r.ctx,
&AsyncFile{
ctx: ctx,
ctx: r.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(ctx, time.Second*20)
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
if err != nil {
@@ -177,9 +177,9 @@ func CreateEventDir(
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(*pointer)
h.AddChild("@root", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + nevent),
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
@@ -187,18 +187,18 @@ func CreateEventDir(
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(*pointer)
h.AddChild("@parent", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + nevent),
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
} else if event.Kind == 1111 {
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
if xp, ok := pointer.(nostr.ExternalPointer); ok {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@root", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
@@ -207,18 +207,18 @@ func CreateEventDir(
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + nevent),
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
}
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
if xp, ok := pointer.(nostr.ExternalPointer); ok {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@parent", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
@@ -227,9 +227,9 @@ func CreateEventDir(
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
ctx,
r.ctx,
&fs.MemSymlink{
Data: []byte(wd + "/" + nevent),
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)

View File

@@ -1,8 +1,16 @@
package nostrfs
import "strconv"
import (
"fiatjaf.com/nostr"
)
func hexToUint64(hexStr string) uint64 {
v, _ := strconv.ParseUint(hexStr[16:32], 16, 64)
return v
func kindToExtension(kind nostr.Kind) string {
switch kind {
case 30023:
return "md"
case 30818:
return "adoc"
default:
return "txt"
}
}

View File

@@ -1,228 +1,261 @@
package nostrfs
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"sync/atomic"
"syscall"
"time"
"github.com/bytedance/sonic"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
sdk "github.com/nbd-wtf/go-nostr/sdk"
"github.com/liamg/magic"
)
type NpubDir struct {
sys *sdk.System
fs.Inode
root *NostrRoot
pointer nostr.ProfilePointer
ctx context.Context
fetched atomic.Bool
}
func CreateNpubDir(
ctx context.Context,
sys *sdk.System,
parent fs.InodeEmbedder,
wd string,
pointer nostr.ProfilePointer,
) *fs.Inode {
npubdir := &NpubDir{ctx: ctx, sys: sys, pointer: pointer}
h := parent.EmbeddedInode().NewPersistentInode(
ctx,
npubdir,
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: hexToUint64(pointer.PublicKey)},
)
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
relays := sys.FetchOutboxRelays(ctx, pointer.PublicKey, 2)
func (r *NostrRoot) CreateNpubDir(
parent fs.InodeEmbedder,
pointer nostr.ProfilePointer,
signer nostr.Signer,
) *fs.Inode {
npubdir := &NpubDir{root: r, pointer: pointer}
return parent.EmbeddedInode().NewPersistentInode(
r.ctx,
npubdir,
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(pointer.PublicKey[8:16])},
)
}
func (h *NpubDir) OnAdd(_ context.Context) {
log := h.root.ctx.Value("log").(func(msg string, args ...any))
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
log("- adding folder for %s with relays %s\n",
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
h.AddChild("pubkey", h.NewPersistentInode(
ctx,
&fs.MemRegularFile{Data: []byte(pointer.PublicKey + "\n"), Attr: fuse.Attr{Mode: 0444}},
h.root.ctx,
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey.Hex() + "\n"), Attr: fuse.Attr{Mode: 0444}},
fs.StableAttr{},
), true)
h.AddChild(
"metadata.json",
h.NewPersistentInode(
ctx,
&AsyncFile{
ctx: ctx,
load: func() ([]byte, nostr.Timestamp) {
pm := sys.FetchProfileMetadata(ctx, pointer.PublicKey)
jsonb, _ := sonic.ConfigFastest.MarshalIndent(pm, "", " ")
var ts nostr.Timestamp
if pm.Event != nil {
ts = pm.Event.CreatedAt
go func() {
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
if pm.Event == nil {
return
}
metadataj, _ := json.MarshalIndent(pm, "", " ")
h.AddChild(
"metadata.json",
h.NewPersistentInode(
h.root.ctx,
&fs.MemRegularFile{
Data: metadataj,
Attr: fuse.Attr{
Mtime: uint64(pm.Event.CreatedAt),
Mode: 0444,
},
},
fs.StableAttr{},
),
true,
)
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
if err == nil {
resp, err := http.DefaultClient.Do(req)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode < 300 {
b := &bytes.Buffer{}
io.Copy(b, resp.Body)
ext := "png"
if ft, err := magic.Lookup(b.Bytes()); err == nil {
ext = ft.Extension
}
return jsonb, ts
},
},
fs.StableAttr{},
),
true,
)
h.AddChild(
"notes",
h.NewPersistentInode(
ctx,
&ViewDir{
ctx: ctx,
sys: sys,
wd: wd,
filter: nostr.Filter{
Kinds: []int{1},
Authors: []string{pointer.PublicKey},
},
paginate: true,
relays: relays,
create: func(ctx context.Context, n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, CreateEventDir(ctx, n, n.wd, event)
},
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
h.AddChild("picture."+ext, h.NewPersistentInode(
ctx,
&fs.MemRegularFile{
Data: b.Bytes(),
Attr: fuse.Attr{
Mtime: uint64(pm.Event.CreatedAt),
Mode: 0444,
},
},
fs.StableAttr{},
), true)
}
}
}
}()
h.AddChild(
"comments",
h.NewPersistentInode(
ctx,
&ViewDir{
ctx: ctx,
sys: sys,
wd: wd,
filter: nostr.Filter{
Kinds: []int{1111},
Authors: []string{pointer.PublicKey},
if h.GetChild("notes") == nil {
h.AddChild(
"notes",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{1},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
createable: true,
},
paginate: true,
relays: relays,
create: func(ctx context.Context, n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, CreateEventDir(ctx, n, n.wd, event)
},
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
h.AddChild(
"pictures",
h.NewPersistentInode(
ctx,
&ViewDir{
ctx: ctx,
sys: sys,
wd: wd,
filter: nostr.Filter{
Kinds: []int{20},
Authors: []string{pointer.PublicKey},
if h.GetChild("comments") == nil {
h.AddChild(
"comments",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{1111},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
},
paginate: true,
relays: relays,
create: func(ctx context.Context, n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, CreateEventDir(ctx, n, n.wd, event)
},
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
h.AddChild(
"videos",
h.NewPersistentInode(
ctx,
&ViewDir{
ctx: ctx,
sys: sys,
wd: wd,
filter: nostr.Filter{
Kinds: []int{21, 22},
Authors: []string{pointer.PublicKey},
if h.GetChild("photos") == nil {
h.AddChild(
"photos",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{20},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
},
paginate: true,
relays: relays,
create: func(ctx context.Context, n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, CreateEventDir(ctx, n, n.wd, event)
},
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
h.AddChild(
"highlights",
h.NewPersistentInode(
ctx,
&ViewDir{
ctx: ctx,
sys: sys,
wd: wd,
filter: nostr.Filter{
Kinds: []int{9802},
Authors: []string{pointer.PublicKey},
if h.GetChild("videos") == nil {
h.AddChild(
"videos",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{21, 22},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: false,
},
paginate: true,
relays: relays,
create: func(ctx context.Context, n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, CreateEventDir(ctx, n, n.wd, event)
},
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
h.AddChild(
"articles",
h.NewPersistentInode(
ctx,
&ViewDir{
ctx: ctx,
sys: sys,
wd: wd,
filter: nostr.Filter{
Kinds: []int{30023},
Authors: []string{pointer.PublicKey},
if h.GetChild("highlights") == nil {
h.AddChild(
"highlights",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{9802},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: false,
},
paginate: false,
relays: relays,
create: func(ctx context.Context, n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.Tags.GetD(), CreateEntityDir(ctx, n, n.wd, ".md", event)
},
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
h.AddChild(
"wiki",
h.NewPersistentInode(
ctx,
&ViewDir{
ctx: ctx,
sys: sys,
wd: wd,
filter: nostr.Filter{
Kinds: []int{30818},
Authors: []string{pointer.PublicKey},
if h.GetChild("articles") == nil {
h.AddChild(
"articles",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{30023},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: true,
createable: true,
},
paginate: false,
relays: relays,
create: func(ctx context.Context, n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.Tags.GetD(), CreateEntityDir(ctx, n, n.wd, ".adoc", event)
},
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
return h
if h.GetChild("wiki") == nil {
h.AddChild(
"wiki",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{30818},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: true,
createable: true,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
}

View File

@@ -6,77 +6,94 @@ import (
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip05"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/sdk"
)
type Options struct {
AutoPublishNotesTimeout time.Duration
AutoPublishArticlesTimeout time.Duration
}
type NostrRoot struct {
fs.Inode
ctx context.Context
wd string
sys *sdk.System
rootPubKey string
rootPubKey nostr.PubKey
signer nostr.Signer
opts Options
}
var _ = (fs.NodeOnAdder)((*NostrRoot)(nil))
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string) *NostrRoot {
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string, o Options) *NostrRoot {
pubkey, _ := user.GetPublicKey(ctx)
signer, _ := user.(nostr.Signer)
abs, _ := filepath.Abs(mountpoint)
var signer nostr.Signer
if user != nil {
signer, _ = user.(nostr.Signer)
}
return &NostrRoot{
ctx: ctx,
sys: sys,
rootPubKey: pubkey,
signer: signer,
wd: abs,
opts: o,
}
}
func (r *NostrRoot) OnAdd(context.Context) {
if r.rootPubKey == "" {
func (r *NostrRoot) OnAdd(_ context.Context) {
if r.rootPubKey == nostr.ZeroPK {
return
}
// add our contacts
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
for _, f := range fl.Items {
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
npub, _ := nip19.EncodePublicKey(f.Pubkey)
r.AddChild(
npub,
CreateNpubDir(r.ctx, r.sys, r, r.wd, pointer),
true,
)
}
go func() {
time.Sleep(time.Millisecond * 100)
// add ourselves
npub, _ := nip19.EncodePublicKey(r.rootPubKey)
if r.GetChild(npub) == nil {
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
r.AddChild(
npub,
CreateNpubDir(r.ctx, r.sys, r, r.wd, pointer),
true,
)
}
// add our contacts
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
for _, f := range fl.Items {
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
r.AddChild(
nip19.EncodeNpub(f.Pubkey),
r.CreateNpubDir(r, pointer, nil),
true,
)
}
// add a link to ourselves
r.AddChild("@me", r.NewPersistentInode(
r.ctx,
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
// add ourselves
npub := nip19.EncodeNpub(r.rootPubKey)
if r.GetChild(npub) == nil {
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
r.AddChild(
npub,
r.CreateNpubDir(r, pointer, r.signer),
true,
)
}
// add a link to ourselves
r.AddChild("@me", r.NewPersistentInode(
r.ctx,
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}()
}
func (r *NostrRoot) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
func (r *NostrRoot) Lookup(_ context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
out.SetEntryTimeout(time.Minute * 5)
child := r.GetChild(name)
@@ -84,9 +101,12 @@ func (r *NostrRoot) Lookup(ctx context.Context, name string, out *fuse.EntryOut)
return child, fs.OK
}
if pp, err := nip05.QueryIdentifier(ctx, name); err == nil {
npubdir := CreateNpubDir(ctx, r.sys, r, r.wd, *pp)
return npubdir, fs.OK
if pp, err := nip05.QueryIdentifier(r.ctx, name); err == nil {
return r.NewPersistentInode(
r.ctx,
&fs.MemSymlink{Data: []byte(r.wd + "/" + nip19.EncodePointer(*pp))},
fs.StableAttr{Mode: syscall.S_IFLNK},
), fs.OK
}
pointer, err := nip19.ToPointer(name)
@@ -96,10 +116,10 @@ func (r *NostrRoot) Lookup(ctx context.Context, name string, out *fuse.EntryOut)
switch p := pointer.(type) {
case nostr.ProfilePointer:
npubdir := CreateNpubDir(ctx, r.sys, r, r.wd, p)
npubdir := r.CreateNpubDir(r, p, nil)
return npubdir, fs.OK
case nostr.EventPointer:
eventdir, err := FetchAndCreateEventDir(ctx, r, r.wd, r.sys, p)
eventdir, err := r.FetchAndCreateEventDir(r, p)
if err != nil {
return nil, syscall.ENOENT
}

View File

@@ -2,36 +2,189 @@ package nostrfs
import (
"context"
"strings"
"sync/atomic"
"syscall"
"fiatjaf.com/lib/debouncer"
"fiatjaf.com/nostr"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
sdk "github.com/nbd-wtf/go-nostr/sdk"
)
type ViewDir struct {
fs.Inode
ctx context.Context
sys *sdk.System
wd string
fetched atomic.Bool
filter nostr.Filter
paginate bool
relays []string
create func(context.Context, *ViewDir, *nostr.Event) (string, *fs.Inode)
root *NostrRoot
fetched atomic.Bool
filter nostr.Filter
paginate bool
relays []string
replaceable bool
createable bool
publisher *debouncer.Debouncer
publishing struct {
note string
}
}
var (
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
_ = (fs.NodeCreater)((*ViewDir)(nil))
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
)
func (n *ViewDir) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
return fs.OK
}
func (n *ViewDir) Create(
_ context.Context,
name string,
flags uint32,
mode uint32,
out *fuse.EntryOut,
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
return nil, nil, 0, syscall.EPERM
}
if n.publisher == nil {
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
}
if n.filter.Kinds[0] != 1 {
return nil, nil, 0, syscall.ENOTSUP
}
switch name {
case "new":
log := n.root.ctx.Value("log").(func(msg string, args ...any))
if n.publisher.IsRunning() {
log("pending note updated, timer reset.")
} else {
log("new note detected")
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
} else {
log(".\n")
}
log("- `touch publish` to publish immediately\n")
log("- `rm new` to erase and cancel the publication.\n")
}
n.publisher.Call(n.publishNote)
first := true
return n.NewPersistentInode(
n.root.ctx,
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
if !first {
log("pending note updated, timer reset.\n")
}
first = false
n.publishing.note = strings.TrimSpace(s)
n.publisher.Call(n.publishNote)
}),
fs.StableAttr{},
), nil, 0, fs.OK
case "publish":
if n.publisher.IsRunning() {
// this causes the publish process to be triggered faster
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing now!\n")
n.publisher.Flush()
return nil, nil, 0, syscall.ENOTDIR
}
}
return nil, nil, 0, syscall.ENOTSUP
}
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
return syscall.EPERM
}
if n.publisher == nil {
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
}
if n.filter.Kinds[0] != 1 {
return syscall.ENOTSUP
}
switch name {
case "new":
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing canceled.\n")
n.publisher.Stop()
n.publishing.note = ""
return fs.OK
}
return syscall.ENOTSUP
}
func (n *ViewDir) publishNote() {
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing note...\n")
evt := nostr.Event{
Kind: 1,
CreatedAt: nostr.Now(),
Content: n.publishing.note,
Tags: make(nostr.Tags, 0, 2),
}
// our write relays
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey)
if len(relays) == 0 {
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
}
// massage and extract tags from raw text
targetRelays := n.root.sys.PrepareNoteEvent(n.root.ctx, &evt)
relays = nostr.AppendUnique(relays, targetRelays...)
// sign and publish
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
log("failed to sign: %s\n", err)
return
}
log(evt.String() + "\n")
log("publishing to %d relays... ", len(relays))
success := false
first := true
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
success = true
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n")
if success {
n.RmChild("new")
n.AddChild(evt.ID.Hex(), n.root.CreateEventDir(n, &evt), true)
log("event published as %s and updated locally.\n", color.BlueString(evt.ID.Hex()))
}
}
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
now := nostr.Now()
if n.filter.Until != nil {
now = *n.filter.Until
if n.filter.Until != 0 {
now = n.filter.Until
}
aMonthAgo := now - 30*24*60*60
out.Mtime = uint64(aMonthAgo)
@@ -46,37 +199,69 @@ func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
if n.paginate {
now := nostr.Now()
if n.filter.Until != nil {
now = *n.filter.Until
if n.filter.Until != 0 {
now = n.filter.Until
}
aMonthAgo := now - 30*24*60*60
n.filter.Since = &aMonthAgo
for ie := range n.sys.Pool.FetchMany(ctx, n.relays, n.filter, nostr.WithLabel("nakfs")) {
basename, inode := n.create(ctx, n, ie.Event)
n.AddChild(basename, inode, true)
}
n.filter.Since = aMonthAgo
filter := n.filter
filter.Until = &aMonthAgo
filter.Until = aMonthAgo
n.AddChild("@previous", n.NewPersistentInode(
ctx,
n.root.ctx,
&ViewDir{
ctx: n.ctx,
sys: n.sys,
filter: filter,
wd: n.wd,
relays: n.relays,
root: n.root,
filter: filter,
relays: n.relays,
replaceable: n.replaceable,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
), true)
}
if n.replaceable {
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter, nostr.SubscriptionOptions{
Label: "nakfs",
}).Range {
name := rkey.D
if name == "" {
name = "_"
}
if n.GetChild(name) == nil {
n.AddChild(name, n.root.CreateEntityDir(n, &evt), true)
}
}
} else {
for ie := range n.sys.Pool.FetchMany(ctx, n.relays, n.filter, nostr.WithLabel("nakfs")) {
basename, inode := n.create(ctx, n, ie.Event)
n.AddChild(basename, inode, true)
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
nostr.SubscriptionOptions{
Label: "nakfs",
}) {
if n.GetChild(ie.Event.ID.Hex()) == nil {
n.AddChild(ie.Event.ID.Hex(), n.root.CreateEventDir(n, &ie.Event), true)
}
}
}
return fs.OK
}
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
return nil, syscall.ENOTSUP
}
if n.replaceable {
// create a template event that can later be modified and published as new
return n.root.CreateEntityDir(n, &nostr.Event{
PubKey: n.root.rootPubKey,
CreatedAt: 0,
Kind: n.filter.Kinds[0],
Tags: nostr.Tags{
nostr.Tag{"d", name},
},
}), fs.OK
}
return nil, syscall.ENOTSUP
}

93
nostrfs/writeablefile.go Normal file
View File

@@ -0,0 +1,93 @@
package nostrfs
import (
"context"
"sync"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type WriteableFile struct {
fs.Inode
root *NostrRoot
mu sync.Mutex
data []byte
attr fuse.Attr
onWrite func(string)
}
var (
_ = (fs.NodeOpener)((*WriteableFile)(nil))
_ = (fs.NodeReader)((*WriteableFile)(nil))
_ = (fs.NodeWriter)((*WriteableFile)(nil))
_ = (fs.NodeGetattrer)((*WriteableFile)(nil))
_ = (fs.NodeSetattrer)((*WriteableFile)(nil))
_ = (fs.NodeFlusher)((*WriteableFile)(nil))
)
func (r *NostrRoot) NewWriteableFile(data string, ctime, mtime uint64, onWrite func(string)) *WriteableFile {
return &WriteableFile{
root: r,
data: []byte(data),
attr: fuse.Attr{
Mode: 0666,
Ctime: ctime,
Mtime: mtime,
Size: uint64(len(data)),
},
onWrite: onWrite,
}
}
func (f *WriteableFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *WriteableFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
offset := int(off)
end := offset + len(data)
if len(f.data) < end {
newData := make([]byte, offset+len(data))
copy(newData, f.data)
f.data = newData
}
copy(f.data[offset:], data)
f.data = f.data[0:end]
f.onWrite(string(f.data))
return uint32(len(data)), fs.OK
}
func (f *WriteableFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.attr
out.Attr.Size = uint64(len(f.data))
return fs.OK
}
func (f *WriteableFile) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
f.attr.Mtime = in.Mtime
f.attr.Atime = in.Atime
f.attr.Ctime = in.Ctime
return fs.OK
}
func (f *WriteableFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
return fs.OK
}
func (f *WriteableFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
end := int(off) + len(dest)
if end > len(f.data) {
end = len(f.data)
}
return fuse.ReadResultData(f.data[off:end]), fs.OK
}

View File

@@ -6,10 +6,45 @@ import (
"os"
"path/filepath"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk"
"fiatjaf.com/nostr/sdk/hints/badgerh"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
)
var (
hintsFilePath string
hintsFileExists bool
)
func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error {
configPath := c.String("config-path")
if configPath == "" {
if home, err := os.UserHomeDir(); err == nil {
configPath = filepath.Join(home, ".config/nak")
}
}
if configPath != "" {
hintsFilePath = filepath.Join(configPath, "outbox/hints.bg")
}
if hintsFilePath != "" {
if _, err := os.Stat(hintsFilePath); !os.IsNotExist(err) {
hintsFileExists = true
} else if err != nil {
return err
}
}
if hintsFileExists && hintsFilePath != "" {
hintsdb, err := badgerh.NewBadgerHints(hintsFilePath)
if err == nil {
sys.Hints = hintsdb
}
}
return nil
}
var outbox = &cli.Command{
Name: "outbox",
Usage: "manage outbox relay hints database",
@@ -27,10 +62,10 @@ var outbox = &cli.Command{
return fmt.Errorf("couldn't find a place to store the hints, pass --config-path to fix.")
}
if err := os.MkdirAll(filepath.Dir(hintsFilePath), 0777); err == nil {
if err := os.WriteFile(hintsFilePath, []byte("{}"), 0644); err != nil {
return fmt.Errorf("failed to create hints database: %w", err)
}
os.MkdirAll(hintsFilePath, 0755)
_, err := badgerh.NewBadgerHints(hintsFilePath)
if err != nil {
return fmt.Errorf("failed to create badger hints db at '%s': %w", hintsFilePath, err)
}
log("initialized hints database at %s\n", hintsFilePath)
@@ -44,20 +79,20 @@ var outbox = &cli.Command{
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if !hintsFileExists {
log("running with temporary fragile data.\n")
log("call `nak outbox init` to setup persistence.\n")
log(color.YellowString("running with temporary fragile data.\n"))
log(color.YellowString("call `nak outbox init` to setup persistence.\n"))
}
if c.Args().Len() != 1 {
return fmt.Errorf("expected exactly one argument (pubkey)")
}
pubkey := c.Args().First()
if !nostr.IsValidPublicKey(pubkey) {
return fmt.Errorf("invalid public key: %s", pubkey)
pk, err := nostr.PubKeyFromHex(c.Args().First())
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err)
}
for _, relay := range sys.FetchOutboxRelays(ctx, pubkey, 6) {
for _, relay := range sys.FetchOutboxRelays(ctx, pk, 6) {
stdout(relay)
}

181
publish.go Normal file
View File

@@ -0,0 +1,181 @@
package main
import (
"context"
"fmt"
"io"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
var publish = &cli.Command{
Name: "publish",
Usage: "publishes a note with content from stdin",
Description: `reads content from stdin and publishes it as a note, optionally as a reply to another note.
example:
echo "hello world" | nak publish
echo "I agree!" | nak publish --reply nevent1...
echo "tagged post" | nak publish -t t=mytag -t e=someeventid`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "reply",
Usage: "event id, naddr1 or nevent1 code to reply to",
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "sets a tag field on the event, takes a value like -t e=<id> or -t sometag=\"value one;value two;value three\"",
},
&NaturalTimeFlag{
Name: "created-at",
Aliases: []string{"time", "ts"},
Usage: "unix timestamp value for the created_at field",
DefaultText: "now",
Value: nostr.Now(),
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "nevent",
Usage: "print the nevent code (to stderr) after the event is published",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "confirm",
Usage: "ask before publishing the event",
Category: CATEGORY_EXTRAS,
},
),
Action: func(ctx context.Context, c *cli.Command) error {
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
evt := nostr.Event{
Kind: 1,
Content: strings.TrimSpace(string(content)),
Tags: make(nostr.Tags, 0, 4),
CreatedAt: nostr.Now(),
}
// handle timestamp flag
if c.IsSet("created-at") {
evt.CreatedAt = getNaturalDate(c, "created-at")
}
// handle reply flag
var replyRelays []string
if replyTo := c.String("reply"); replyTo != "" {
var replyEvent *nostr.Event
// try to decode as nevent or naddr first
if strings.HasPrefix(replyTo, "nevent1") || strings.HasPrefix(replyTo, "naddr1") {
_, value, err := nip19.Decode(replyTo)
if err != nil {
return fmt.Errorf("invalid reply target: %w", err)
}
switch pointer := value.(type) {
case nostr.EventPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
case nostr.EntityPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
}
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
} else {
// try as raw event ID
id, err := nostr.IDFromHex(replyTo)
if err != nil {
return fmt.Errorf("invalid event id: %w", err)
}
replyEvent, _, err = sys.FetchSpecificEvent(ctx, nostr.EventPointer{ID: id}, sdk.FetchSpecificEventParameters{})
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
}
if replyEvent.Kind != 1 {
evt.Kind = 1111
}
// add reply tags
evt.Tags = append(evt.Tags,
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
nostr.Tag{"p", replyEvent.PubKey.Hex()},
)
replyRelays = sys.FetchInboxRelays(ctx, replyEvent.PubKey, 3)
}
// handle other tags -- copied from event.go
tagFlags := c.StringSlice("tag")
for _, tagFlag := range tagFlags {
// tags are in the format key=value
tagName, tagValue, found := strings.Cut(tagFlag, "=")
tag := []string{tagName}
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
tag = append(tag, tagValues...)
}
evt.Tags = append(evt.Tags, tag)
}
// process the content
targetRelays := sys.PrepareNoteEvent(ctx, &evt)
// connect to all the relays (like event.go)
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get our public key: %w", err)
}
relayUrls := sys.FetchWriteRelays(ctx, pk)
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{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},
)
if len(relays) == 0 {
if len(relayUrls) == 0 {
return fmt.Errorf("no relays to publish this note to.")
} else {
return fmt.Errorf("failed to connect to any of [ %v ].", relayUrls)
}
}
// sign the event
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("error signing event: %w", err)
}
// print
stdout(evt.String())
// publish (like event.go)
return publishFlow(ctx, c, kr, evt, relays)
},
}

View File

@@ -10,9 +10,9 @@ import (
"io"
"net/http"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip11"
"github.com/nbd-wtf/go-nostr/nip86"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip86"
"github.com/urfave/cli/v3"
)

84
req.go
View File

@@ -6,9 +6,11 @@ import (
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip77"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip77"
"github.com/urfave/cli/v3"
)
@@ -76,35 +78,33 @@ example:
Action: func(ctx context.Context, c *cli.Command) error {
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
relays := connectToAllRelays(ctx,
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
c.Bool("force-pre-auth"),
nostr.WithAuthHandler(
func(ctx context.Context, authEvent nostr.RelayEvent) (err error) {
defer func() {
if err != nil {
log("auth to %s failed: %s\n",
(*authEvent.Tags.GetFirst([]string{"relay", ""}))[1],
err,
forcePreAuthSigner,
nostr.PoolOptions{
AuthHandler: 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"):]
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth not authorized")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
log("performing auth as %s... ", pk)
return kr.SignEvent(ctx, authEvent.Event)
log(s+"\n", args...)
}, authEvent)
},
),
)
})
// stop here already if all connections failed
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -113,14 +113,9 @@ example:
for i, relay := range relays {
relayUrls[i] = relay.URL
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
}
// go line by line from stdin or run once with input from flags
for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{}
if stdinFilter != "" {
@@ -136,7 +131,7 @@ example:
if len(relayUrls) > 0 {
if c.Bool("ids-only") {
seen := make(map[string]struct{}, max(500, filter.Limit))
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
for _, url := range relayUrls {
ch, err := nip77.FetchIDsOnly(ctx, url, filter)
if err != nil {
@@ -159,7 +154,7 @@ example:
fn = sys.Pool.SubscribeMany
}
for ie := range fn(ctx, relayUrls, filter) {
for ie := range fn(ctx, relayUrls, filter, nostr.SubscriptionOptions{}) {
stdout(ie.Event)
}
}
@@ -169,7 +164,7 @@ example:
if c.Bool("bare") {
result = filter.String()
} else {
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: nostr.Filters{filter}})
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filter: filter})
result = string(j)
}
@@ -183,13 +178,13 @@ example:
}
var reqFilterFlags = []cli.Flag{
&cli.StringSliceFlag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
&IDSliceFlag{
Name: "id",
Aliases: []string{"i"},
Usage: "only accept events with these ids (hex)",
@@ -248,14 +243,14 @@ var reqFilterFlags = []cli.Flag{
}
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
if authors := c.StringSlice("author"); len(authors) > 0 {
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = append(filter.Authors, authors...)
}
if ids := c.StringSlice("id"); len(ids) > 0 {
if ids := getIDSlice(c, "id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
for _, kind64 := range c.IntSlice("kind") {
filter.Kinds = append(filter.Kinds, int(kind64))
filter.Kinds = append(filter.Kinds, nostr.Kind(kind64))
}
if search := c.String("search"); search != "" {
filter.Search = search
@@ -291,13 +286,10 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
}
if c.IsSet("since") {
nts := getNaturalDate(c, "since")
filter.Since = &nts
filter.Since = getNaturalDate(c, "since")
}
if c.IsSet("until") {
nts := getNaturalDate(c, "until")
filter.Until = &nts
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Uint("limit"); limit != 0 {

View File

@@ -4,15 +4,15 @@ import (
"bufio"
"context"
"fmt"
"math"
"os"
"sync/atomic"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/khatru"
"github.com/bep/debounce"
"github.com/fatih/color"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v3"
)
@@ -38,7 +38,7 @@ var serve = &cli.Command{
},
},
Action: func(ctx context.Context, c *cli.Command) error {
db := slicestore.SliceStore{MaxLimit: math.MaxInt}
db := &slicestore.SliceStore{}
var scanner *bufio.Scanner
if path := c.String("events"); path != "" {
@@ -59,7 +59,7 @@ var serve = &cli.Command{
if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
return fmt.Errorf("invalid event received at line %d: %s (`%s`)", i, err, scanner.Text())
}
db.SaveEvent(ctx, &evt)
db.SaveEvent(evt)
i++
}
}
@@ -71,10 +71,7 @@ var serve = &cli.Command{
rl.Info.Software = "https://github.com/fiatjaf/nak"
rl.Info.Version = version
rl.QueryEvents = append(rl.QueryEvents, db.QueryEvents)
rl.CountEvents = append(rl.CountEvents, db.CountEvents)
rl.DeleteEvent = append(rl.DeleteEvent, db.DeleteEvent)
rl.StoreEvent = append(rl.StoreEvent, db.SaveEvent)
rl.UseEventstore(db, 1_000_000)
started := make(chan bool)
exited := make(chan error)
@@ -90,33 +87,48 @@ var serve = &cli.Command{
var printStatus func()
// relay logging
rl.RejectFilter = append(rl.RejectFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiYellowString("request"), colors.italic(filter))
printStatus()
return false, ""
})
rl.RejectCountFilter = append(rl.RejectCountFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
}
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
printStatus()
return false, ""
})
rl.RejectEvent = append(rl.RejectEvent, func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
}
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
printStatus()
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 := 0
ch, _ := db.QueryEvents(ctx, nostr.Filter{})
for range ch {
totalEvents++
totalEvents, err := db.CountEvents(nostr.Filter{})
if err != nil {
log("failed to count: %s\n", err)
}
subs := rl.GetListeningFilters()
log(" %s events stored: %s, subscriptions opened: %s\n", color.HiMagentaString("•"), color.HiMagentaString("%d", totalEvents), color.HiMagentaString("%d", len(subs)))
log(" %s events: %s, connections: %s, subscriptions: %s\n",
color.HiMagentaString("•"),
color.HiMagentaString("%d", totalEvents),
color.HiMagentaString("%d", totalConnections.Load()),
color.HiMagentaString("%d", len(subs)),
)
})
}

View File

@@ -3,8 +3,8 @@ package main
import (
"context"
"fiatjaf.com/nostr"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr"
)
var verify = &cli.Command{
@@ -30,8 +30,8 @@ it outputs nothing if the verification is successful.`,
continue
}
if ok, err := evt.CheckSignature(); !ok {
ctx = lineProcessingError(ctx, "invalid signature: %v", err)
if !evt.VerifySignature() {
ctx = lineProcessingError(ctx, "invalid signature")
continue
}
}

116
wallet.go
View File

@@ -6,11 +6,10 @@ import (
"strconv"
"strings"
"github.com/fatih/color"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip60"
"github.com/nbd-wtf/go-nostr/nip61"
"github.com/nbd-wtf/go-nostr/sdk"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip60"
"fiatjaf.com/nostr/nip61"
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
@@ -26,12 +25,12 @@ 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)
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
if w == nil {
return nil, nil, fmt.Errorf("error loading walle")
}
w.Processed = func(evt *nostr.Event, err error) {
w.Processed = func(evt nostr.Event, err error) {
if err == nil {
logverbose("processed event %s\n", evt)
} else {
@@ -60,20 +59,16 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
log("- saving kind:%d event (%s)... ", event.Kind, desc)
first := true
for res := range sys.Pool.PublishMany(ctx, relays, event) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else {
log("%s: ok", color.GreenString(cleanUrl))
log("%s: ok", colors.successf(cleanUrl))
}
}
log("\n")
@@ -205,12 +200,9 @@ var wallet = &cli.Command{
return err
}
opts := make([]nip60.ReceiveOption, 0, 1)
for _, url := range c.StringSlice("mint") {
opts = append(opts, nip60.WithMintDestination(url))
}
if err := w.Receive(ctx, proofs, mint, opts...); err != nil {
if err := w.Receive(ctx, proofs, mint, nip60.ReceiveOptions{
IntoMint: c.StringSlice("mint"),
}); err != nil {
return err
}
@@ -244,12 +236,13 @@ var wallet = &cli.Command{
return err
}
opts := make([]nip60.SendOption, 0, 1)
var sourceMint string
if mint := c.String("mint"); mint != "" {
mint = "http" + nostr.NormalizeURL(mint)[2:]
opts = append(opts, nip60.WithMint(mint))
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
}
proofs, mint, err := w.Send(ctx, amount, opts...)
proofs, mint, err := w.SendInternal(ctx, amount, nip60.SendOptions{
SpecificSourceMint: sourceMint,
})
if err != nil {
return err
}
@@ -282,13 +275,14 @@ var wallet = &cli.Command{
return err
}
opts := make([]nip60.SendOption, 0, 1)
var sourceMint string
if mint := c.String("mint"); mint != "" {
mint = "http" + nostr.NormalizeURL(mint)[2:]
opts = append(opts, nip60.WithMint(mint))
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
}
preimage, err := w.PayBolt11(ctx, args[0], opts...)
preimage, err := w.PayBolt11(ctx, args[0], nip60.PayOptions{
FromMint: sourceMint,
})
if err != nil {
return err
}
@@ -326,11 +320,16 @@ var wallet = &cli.Command{
return err
}
amount := c.Uint("amount")
amount, err := strconv.ParseInt(c.Args().First(), 10, 64)
if err != nil {
return fmt.Errorf("invalid amount '%s': %w", c.Args().First(), err)
}
target := c.String("target")
var pm sdk.ProfileMetadata
var evt *nostr.Event
var eventId string
var eventId nostr.ID
if strings.HasPrefix(target, "nevent1") {
evt, _, err = sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{
@@ -340,20 +339,19 @@ var wallet = &cli.Command{
return err
}
eventId = evt.ID
target = evt.PubKey
}
pm, err := sys.FetchProfileFromInput(ctx, target)
if err != nil {
return err
pm = sys.FetchProfileMetadata(ctx, evt.PubKey)
} else {
pm, err = sys.FetchProfileFromInput(ctx, target)
if err != nil {
return err
}
}
log("sending %d sat to '%s' (%s)", amount, pm.ShortName(), pm.Npub())
opts := make([]nip60.SendOption, 0, 1)
var sourceMint string
if mint := c.String("mint"); mint != "" {
mint = "http" + nostr.NormalizeURL(mint)[2:]
opts = append(opts, nip60.WithMint(mint))
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
}
kr, _, _ := gatherKeyerFromArguments(ctx, c)
@@ -362,12 +360,15 @@ var wallet = &cli.Command{
kr,
w,
sys.Pool,
uint64(amount),
pm.PubKey,
sys.FetchInboxRelays,
sys.FetchOutboxRelays(ctx, pm.PubKey, 3),
eventId,
amount,
c.String("message"),
sys.FetchWriteRelays(ctx, pm.PubKey),
nip61.NutzapOptions{
Message: c.String("message"),
SendToRelays: sys.FetchInboxRelays(ctx, pm.PubKey, 3),
EventID: eventId,
SpecificSourceMint: sourceMint,
},
)
if err != nil {
return err
@@ -376,19 +377,15 @@ var wallet = &cli.Command{
log("- publishing nutzap... ")
first := true
for res := range results {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else {
log("%s: ok", color.GreenString(cleanUrl))
log("%s: ok", colors.successf(cleanUrl))
}
}
@@ -435,14 +432,14 @@ var wallet = &cli.Command{
kr, _, _ := gatherKeyerFromArguments(ctx, c)
pk, _ := kr.GetPublicKey(ctx)
relays := sys.FetchWriteRelays(ctx, pk, 6)
relays := sys.FetchWriteRelays(ctx, pk)
info := nip61.Info{}
ie := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{
Kinds: []int{10019},
Authors: []string{pk},
Kinds: []nostr.Kind{10019},
Authors: []nostr.PubKey{pk},
Limit: 1,
})
}, nostr.SubscriptionOptions{})
if ie != nil {
info.ParseEvent(ie.Event)
}
@@ -463,10 +460,7 @@ var wallet = &cli.Command{
log("- saving kind:10019 event... ")
first := true
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
@@ -474,9 +468,9 @@ var wallet = &cli.Command{
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else {
log("%s: ok", color.GreenString(cleanUrl))
log("%s: ok", colors.successf(cleanUrl))
}
}

View File

@@ -1,14 +1,7 @@
nak:
cli:
name: nak
summary: a command line tool for doing all things nostr
repository: https://github.com/fiatjaf/nak
artifacts:
nak-v%v-darwin-arm64:
platforms: [darwin-arm64]
nak-v%v-darwin-amd64:
platforms: [darwin-x86_64]
nak-v%v-linux-arm64:
platforms: [linux-aarch64]
nak-v%v-linux-amd64:
platforms: [linux-x86_64]
repository: https://github.com/fiatjaf/nak
assets:
- nak-v\d+\.\d+\.\d+-darwin-arm64
- nak-v\d+\.\d+\.\d+-linux-amd64
- nak-v\d+\.\d+\.\d+-linux-arm64
remote_metadata:
- github