Compare commits

..

36 Commits

Author SHA1 Message Date
fiatjaf
1dab81f77c add examples to README. 2025-12-01 21:16:01 -03:00
fiatjaf
11228d7082 gift-wrap. 2025-12-01 21:02:20 -03:00
fiatjaf
a422b5f708 sync command for using a negentropy hack to sync two relays with each other.
closes https://github.com/fiatjaf/nak/issues/84
2025-12-01 20:33:18 -03:00
fiatjaf
852fe6bdfb git: more resiliency when updating nip34.json 2025-11-30 22:21:56 -03:00
fiatjaf
210cf66d5f git: fix a bunch of small bugs. 2025-11-30 08:57:27 -03:00
fiatjaf
f9335b0ab4 git: fetch repo from owner+identifier on init, and other things. 2025-11-27 23:59:46 -03:00
fiatjaf
16916d7d95 nip: display markdown directly, default to list. 2025-11-27 12:14:02 -03:00
fiatjaf
3ff4dbe196 force update golang version.
fixes nostr:nevent1qvzqqqqqqypzqwlsccluhy6xxsr6l9a9uhhxf75g85g8a709tprjcn4e42h053vaqydhwumn8ghj7un9d3shjtnhv4ehgetjde38gcewvdhk6tcprfmhxue69uhhq7tjv9kkjepwve5kzar2v9nzucm0d5hsqgzdaekrxfhwrex49f6htd7rvmnfxs40ypga9mx7hvssaz347mxees2gpdzr
2025-11-27 07:06:53 -03:00
reis
2de3ff78ee Add nip command (#83) 2025-11-26 09:02:47 -03:00
fiatjaf
03c1bf832e fix README misformatting. 2025-11-25 22:44:03 -03:00
fiatjaf
8df130a822 git: handle "pull" modes correctly and stop deleting and recreating remotes all the time. 2025-11-25 14:51:24 -03:00
fiatjaf
e04861fcee git: allow gitSync to not fail if the state is broken. 2025-11-25 14:51:24 -03:00
Yasuhiro Matsumoto
73d80203a0 fix error message 2025-11-25 14:35:16 -03:00
fiatjaf
c3cb59a94a git: move things around, allow for nil state as a possible value, fix syncing when repository is not announced yet. 2025-11-24 23:33:07 -03:00
fiatjaf
59edaba5b8 git: much nicer prompts on "init". 2025-11-24 15:46:44 -03:00
fiatjaf
11a690b1c6 git: fix sync publishing wrong repo event and always being mismatched. 2025-11-24 06:37:42 -03:00
fiatjaf
9f8679591e git: remove unused gitAnnounce. 2025-11-23 21:33:32 -03:00
fiatjaf
75c1a88333 git: push needed to update refs from the state after pushing. 2025-11-23 21:33:19 -03:00
fiatjaf
26fc7c338a git: nip34.json into repository object helpers. 2025-11-23 21:32:33 -03:00
fiatjaf
ddc009a391 git: rework it to be more git-native and expose the internals more in a cool way. 2025-11-23 18:01:25 -03:00
fiatjaf
68e49fa6e5 git: fix the local/remote madness finally I think. 2025-11-23 14:50:19 -03:00
fiatjaf
79c1a70683 git: cleanup. 2025-11-21 23:25:23 -03:00
fiatjaf
77afab780b git: fetch and pull (wip). 2025-11-21 20:01:55 -03:00
fiatjaf
a4f53021f0 add examples for newer use cases. 2025-11-21 20:01:55 -03:00
fiatjaf
afa31a58fc serve: --negentropy 2025-11-21 20:01:55 -03:00
fiatjaf
26f9b33d53 git clone 2025-11-20 23:51:45 -03:00
fiatjaf
51876f89c4 git: nicer logs and fix announce to update only and all outdated relays. 2025-11-19 00:29:30 -03:00
fiatjaf
ae3cb7c108 serve: blossom and grasp support. 2025-11-19 00:29:30 -03:00
fiatjaf
bec821d3c0 build with latest nostrlib.
we had to do this git thing just so we could publish nostrlib to grasp servers and make it downloadable as a dependency, now finally.
2025-11-18 11:57:16 -03:00
fiatjaf
5d7240b112 git betterments with remote and branch determination, force-push and fast-forward check. 2025-11-18 08:14:12 -03:00
Lez
bbe1661096 Don't emit hello event if no events were received from stdin
When running `nak req ... relay.one | nak event relay.two`,
if the first req doesn't return any events, the second
nak should not publish a "hello from nostr army knife" note
to the second relay as it is clearly not the intention.

`nak event relay.two` behavior is unchanged, it will publish the hello.
2025-11-18 08:13:47 -03:00
fiatjaf
ea4ad84aa0 "nak git" command with "init", "announce" and "push". 2025-11-17 13:05:47 -03:00
fiatjaf
85a04aa7ce req --only-missing for negentropy downloading. 2025-11-13 16:16:34 -03:00
fiatjaf
e0ca768695 also parse npub/nevent/naddr when used as tag values, turn them into their corresponding hex or address format. 2025-11-11 16:32:14 -03:00
fiatjaf
bef3739a67 accept npub/nprofile/nevent instead of just hex in flags. 2025-11-11 15:58:53 -03:00
fiatjaf
210c0aa282 update nostrlib again, mostly for the blossom client timeout issue. 2025-11-04 09:18:21 -03:00
19 changed files with 2895 additions and 70 deletions

View File

@@ -47,6 +47,11 @@ nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw
}
```
### fetch all events except those that are present in a given line-delimited json file (negentropy sync)
```shell
~> nak req --only-missing ./events.jsonl -k 30617 pyramid.fiatjaf.com
```
### fetch an event using relay and author hints automatically from a nevent1 code, pretty-print it
```shell
nak fetch nevent1qqs2e3k48vtrkzjm8vvyzcmsmkf58unrxtq2k4h5yspay6vhcqm4wqcpz9mhxue69uhkummnw3ezuamfdejj7q3ql2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqxpqqqqqqz7ttjyq | jq
@@ -183,7 +188,6 @@ you can also display a QR code for the bunker URI by adding the `--qrcode` flag:
~> nak bunker --persist --sec ncryptsec1... relay.nsec.app nos.lol
```
```shell
then later just
```shell
@@ -223,6 +227,21 @@ or give it a named profile:
• events stored: 4, subscriptions opened: 1
```
### enable negentropy (nip77) support in your development relay
```shell
~> nak serve --negentropy
```
### run a grasp server (with a relay)
```shell
~> nak serve --grasp
```
### run a blossom server (with a relay)
```shell
~> nak serve --blossom
```
### make an event with a PoW target
```shell
~> nak event -c 'hello getwired.app and labour.fiatjaf.com' --pow 24
@@ -305,7 +324,27 @@ echo "#surely you're joking, mr npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn6
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 1222 --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
```
### gift-wrap an event to a recipient and publish it somewhere
```shell
~> nak event -c 'secret message' | nak gift wrap --sec <my-secret-key> -p <recipient-public-key> | nak event wss://dmrelay.com
```
### download a gift-wrap event and unwrap it
```shell
~> nak req -p <my-public-key> -k 1059 relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
```
### sync events between two relays using negentropy
```shell
~> nak sync relay1.com relay2.com
```
### from a file with events get only those that have kind 1111 and were created by a given pubkey
```shell
~> cat all.jsonl | nak filter -k 1111 -a 117673e191b10fe1aedf1736ee74de4cffd4c132ca701960b70a5abad5870faa > filtered.jsonl
```
### use negentropy (nip77) to only fetch the ids for a given query
```shell
~> nak req --ids-only -k 1111 -a npub1vyrx2prp0mne8pczrcvv38ahn5wahsl8hlceeu3f3aqyvmu8zh5s7kfy55 relay.damus.io
```

View File

@@ -7,7 +7,6 @@ import (
"io"
"os"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/urfave/cli/v3"
@@ -38,11 +37,11 @@ var blossomCmd = &cli.Command{
var client *blossom.Client
pubkey := c.Args().First()
if pubkey != "" {
if pk, err := nostr.PubKeyFromHex(pubkey); err != nil {
pk, err := parsePubKey(pubkey)
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
} else {
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
}
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
} else {
var err error
client, err = getBlossomClient(ctx, c)

View File

@@ -21,7 +21,7 @@ var count = &cli.Command{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
@@ -101,16 +101,16 @@ var count = &cli.Command{
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)

View File

@@ -163,7 +163,7 @@ var encode = &cli.Command{
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
id, err := nostr.IDFromHex(target)
id, err := parseEventID(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue

View File

@@ -17,7 +17,7 @@ var encrypt = &cli.Command{
defaultKeyFlags,
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey"},
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
Required: true,
},
&cli.BoolFlag{
@@ -79,7 +79,7 @@ var decrypt = &cli.Command{
defaultKeyFlags,
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey"},
Aliases: []string{"p", "src", "source", "pubkey", "from"},
Required: true,
},
&cli.BoolFlag{
@@ -111,7 +111,7 @@ var decrypt = &cli.Command{
}
plaintext, err := nip04.Decrypt(ciphertext, ss)
if err != nil {
return fmt.Errorf("failed to encrypt as nip04: %w", err)
return fmt.Errorf("failed to decrypt as nip04: %w", err)
}
stdout(plaintext)
}

View File

@@ -211,24 +211,30 @@ example:
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
if len(tagValues) >= 1 {
tagValues[0] = decodeTagValue(tagValues[0])
}
tag = append(tag, tagValues...)
}
tags = append(tags, tag)
}
for _, etag := range c.StringSlice("e") {
if tags.FindWithValue("e", etag) == nil {
tags = append(tags, nostr.Tag{"e", etag})
decodedEtag := decodeTagValue(etag)
if tags.FindWithValue("e", decodedEtag) == nil {
tags = append(tags, nostr.Tag{"e", decodedEtag})
}
}
for _, ptag := range c.StringSlice("p") {
if tags.FindWithValue("p", ptag) == nil {
tags = append(tags, nostr.Tag{"p", ptag})
decodedPtag := decodeTagValue(ptag)
if tags.FindWithValue("p", decodedPtag) == nil {
tags = append(tags, nostr.Tag{"p", decodedPtag})
}
}
for _, dtag := range c.StringSlice("d") {
if tags.FindWithValue("d", dtag) == nil {
tags = append(tags, nostr.Tag{"d", dtag})
decodedDtag := decodeTagValue(dtag)
if tags.FindWithValue("d", decodedDtag) == nil {
tags = append(tags, nostr.Tag{"d", decodedDtag})
}
}
if len(tags) > 0 {

View File

@@ -96,8 +96,8 @@ func (t pubkeyValue) Create(val nostr.PubKey, p *nostr.PubKey, c struct{}) cli.V
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
pubkey, err := parsePubKey(value)
t.pubkey = pubkey
t.hasBeenSet = true
return err
}
@@ -147,8 +147,8 @@ func (t idValue) Create(val nostr.ID, p *nostr.ID, c struct{}) cli.Value {
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
id, err := parseEventID(value)
t.id = id
t.hasBeenSet = true
return err
}

192
gift.go Normal file
View File

@@ -0,0 +1,192 @@
package main
import (
"context"
"fmt"
"math/rand"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip44"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
var gift = &cli.Command{
Name: "gift",
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
Description: `example:
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>`,
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
{
Name: "wrap",
Flags: append(
defaultKeyFlags,
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
Required: true,
},
),
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
Description: `example:
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
recipient := getPubKey(c, "recipient-pubkey")
// get sender pubkey
sender, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get sender pubkey: %w", err)
}
// read event from stdin
for eventJSON := range getJsonsOrBlank() {
if eventJSON == "{}" {
continue
}
var originalEvent nostr.Event
if err := easyjson.Unmarshal([]byte(eventJSON), &originalEvent); err != nil {
return fmt.Errorf("invalid event JSON: %w", err)
}
// turn into rumor (unsigned event)
rumor := originalEvent
rumor.Sig = [64]byte{} // remove signature
rumor.PubKey = sender
rumor.ID = rumor.GetID() // compute ID
// create seal
rumorJSON, _ := easyjson.Marshal(rumor)
encryptedRumor, err := kr.Encrypt(ctx, string(rumorJSON), recipient)
if err != nil {
return fmt.Errorf("failed to encrypt rumor: %w", err)
}
seal := &nostr.Event{
Kind: 13,
Content: encryptedRumor,
PubKey: sender,
CreatedAt: randomNow(),
Tags: nostr.Tags{},
}
if err := kr.SignEvent(ctx, seal); err != nil {
return fmt.Errorf("failed to sign seal: %w", err)
}
// create gift wrap
ephemeral := nostr.Generate()
sealJSON, _ := easyjson.Marshal(seal)
convkey, err := nip44.GenerateConversationKey(recipient, ephemeral)
if err != nil {
return fmt.Errorf("failed to generate conversation key: %w", err)
}
encryptedSeal, err := nip44.Encrypt(string(sealJSON), convkey)
if err != nil {
return fmt.Errorf("failed to encrypt seal: %w", err)
}
wrap := &nostr.Event{
Kind: 1059,
Content: encryptedSeal,
CreatedAt: randomNow(),
Tags: nostr.Tags{{"p", recipient.Hex()}},
}
wrap.Sign(ephemeral)
// print the gift-wrap
wrapJSON, err := easyjson.Marshal(wrap)
if err != nil {
return fmt.Errorf("failed to marshal gift wrap: %w", err)
}
stdout(string(wrapJSON))
}
return nil
},
},
{
Name: "unwrap",
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
Description: `example:
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>`,
Flags: append(
defaultKeyFlags,
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey", "from"},
Required: true,
},
),
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
sender := getPubKey(c, "sender-pubkey")
// read gift-wrapped event from stdin
for wrapJSON := range getJsonsOrBlank() {
if wrapJSON == "{}" {
continue
}
var wrap nostr.Event
if err := easyjson.Unmarshal([]byte(wrapJSON), &wrap); err != nil {
return fmt.Errorf("invalid gift wrap JSON: %w", err)
}
if wrap.Kind != 1059 {
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
}
ephemeralPubkey := wrap.PubKey
// decrypt seal
sealJSON, err := kr.Decrypt(ctx, wrap.Content, ephemeralPubkey)
if err != nil {
return fmt.Errorf("failed to decrypt seal: %w", err)
}
var seal nostr.Event
if err := easyjson.Unmarshal([]byte(sealJSON), &seal); err != nil {
return fmt.Errorf("invalid seal JSON: %w", err)
}
if seal.Kind != 13 {
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
}
// decrypt rumor
rumorJSON, err := kr.Decrypt(ctx, seal.Content, sender)
if err != nil {
return fmt.Errorf("failed to decrypt rumor: %w", err)
}
var rumor nostr.Event
if err := easyjson.Unmarshal([]byte(rumorJSON), &rumor); err != nil {
return fmt.Errorf("invalid rumor JSON: %w", err)
}
// output the unwrapped event (rumor)
stdout(rumorJSON)
}
return nil
},
},
},
}
func randomNow() nostr.Timestamp {
const twoDays = 2 * 24 * 60 * 60
now := time.Now().Unix()
randomOffset := rand.Int63n(twoDays)
return nostr.Timestamp(now - randomOffset)
}

1543
git.go Normal file

File diff suppressed because it is too large Load Diff

42
go.mod
View File

@@ -1,65 +1,90 @@
module github.com/fiatjaf/nak
go 1.24.1
go 1.25
require (
fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.0-20250907220143-b67e3092b02e
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.5
github.com/btcsuite/btcd/btcec/v2 v2.3.6
github.com/charmbracelet/glamour v0.10.0
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.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/mailru/easyjson v0.9.1
github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-tty v0.0.7
github.com/mdp/qrterminal/v3 v3.2.1
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.0.0-beta1
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
golang.org/x/sync v0.16.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/sync v0.18.0
golang.org/x/term v0.32.0
)
require (
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bluekeyes/go-gitdiff v0.7.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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/chzyer/logex v1.1.10 // indirect
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.11.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.4.2 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/templexxx/cpu v0.0.1 // indirect
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect
github.com/tetratelabs/wazero v1.8.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
@@ -68,6 +93,9 @@ require (
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
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect

127
go.sum
View File

@@ -1,18 +1,36 @@
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-20250907220143-b67e3092b02e h1:SnM2u7nAlK5rUFJsQYOdTUOAbz0B9Z9ihq95akFHdOE=
fiatjaf.com/nostr v0.0.0-20250907220143-b67e3092b02e/go.mod h1:RHPNZ7jtRi7dZf590g1urHYhWXnbetvd1oByNbAlgKE=
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157 h1:14yLsO2HwpS2CLIKFvLMDp8tVEDahwdC8OeG6NGaL+M=
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
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/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
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/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bluekeyes/go-gitdiff v0.7.1 h1:graP4ElLRshr8ecu0UtqfNTCHrtSyZd3DABQm/DWesQ=
github.com/bluekeyes/go-gitdiff v0.7.1/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
@@ -20,8 +38,8 @@ github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU=
github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.6 h1:IzlsEr9olcSRKB/n7c4351F3xHKxS2lma+1UFGCYd4E=
github.com/btcsuite/btcd/btcec/v2 v2.3.6/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
@@ -43,6 +61,22 @@ 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/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
@@ -51,6 +85,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWs
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
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=
@@ -66,6 +102,8 @@ github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXR
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
@@ -81,6 +119,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
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/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/golang/protobuf v1.2.0/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=
@@ -97,6 +137,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
@@ -104,6 +146,10 @@ github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
github.com/hanwen/go-fuse/v2 v2.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw=
github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
@@ -114,29 +160,46 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.8.3 h1:IzlyN8BaP4YwUMUDqxOGJhGdZXEDQiAPX43dNPgnzrg=
github.com/mark3labs/mcp-go v0.8.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
github.com/markusmobius/go-dateparser v1.2.3 h1:TvrsIvr5uk+3v6poDjaicnAFJ5IgtFHgLiuMY2Eb7Nw=
github.com/markusmobius/go-dateparser v1.2.3/go.mod h1:cMwQRrBUQlK1UI5TIFHEcvpsMbkWrQLXuaPNMFzuYLk=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=
github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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=
@@ -144,6 +207,10 @@ 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/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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=
@@ -159,6 +226,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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=
@@ -170,6 +243,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY=
github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg=
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ=
github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmcF4g=
github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
@@ -194,29 +271,45 @@ github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2e
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -224,17 +317,28 @@ 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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -243,8 +347,9 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
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.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -18,6 +18,7 @@ import (
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk"
@@ -75,7 +76,7 @@ func getJsonsOrBlank() iter.Seq[string] {
return true
})
if !hasStdin {
if !hasStdin && !isPiped() {
yield("{}")
}
@@ -464,6 +465,70 @@ func askConfirmation(msg string) bool {
}
}
func parsePubKey(value string) (nostr.PubKey, error) {
// try nip05 first
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err == nil {
return pp.PublicKey, nil
}
// if nip05 fails, fall through to try as pubkey
}
pk, err := nostr.PubKeyFromHex(value)
if err == nil {
return pk, nil
}
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "npub":
if pk, ok := decoded.(nostr.PubKey); ok {
return pk, nil
}
case "nprofile":
if profile, ok := decoded.(nostr.ProfilePointer); ok {
return profile.PublicKey, nil
}
}
}
return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value)
}
func parseEventID(value string) (nostr.ID, error) {
id, err := nostr.IDFromHex(value)
if err == nil {
return id, nil
}
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "note":
if id, ok := decoded.(nostr.ID); ok {
return id, nil
}
case "nevent":
if event, ok := decoded.(nostr.EventPointer); ok {
return event.ID, nil
}
}
}
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
}
func decodeTagValue(value string) string {
if strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1") || strings.HasPrefix(value, "nprofile1") || strings.HasPrefix(value, "naddr1") {
if ptr, err := nip19.ToPointer(value); err == nil {
return ptr.AsTagReference()
}
}
return value
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string

View File

@@ -77,7 +77,7 @@ func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (
clientKey = nostr.Generate()
}
logverbose("[nip46]: connecting to %s with client key %s", bunkerURL, clientKey.Hex())
logverbose("[nip46]: connecting to %s with client key %s\n", bunkerURL, clientKey.Hex())
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, nil, func(s string) {
log(color.CyanString("[nip46]: open the following URL: %s"), s)

View File

@@ -42,12 +42,16 @@ var app = &cli.Command{
blossomCmd,
encrypt,
decrypt,
gift,
outbox,
wallet,
mcpServer,
curl,
fsCmd,
publish,
git,
nip,
syncCmd,
},
Version: version,
Flags: []cli.Flag{

201
nip.go Normal file
View File

@@ -0,0 +1,201 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"os/exec"
"runtime"
"strings"
"github.com/charmbracelet/glamour"
"github.com/urfave/cli/v3"
)
type nipInfo struct {
nip, desc, link string
}
var nip = &cli.Command{
Name: "nip",
Usage: "list NIPs or get the description of a NIP from its number",
Description: `lists NIPs, fetches and displays NIP text, or opens a NIP page in the browser.
examples:
nak nip # list all NIPs
nak nip 29 # shows nip29 details
nak nip open 29 # opens nip29 in browser`,
ArgsUsage: "[NIP number]",
Commands: []*cli.Command{
{
Name: "open",
Usage: "open the NIP page in the browser",
Action: func(ctx context.Context, c *cli.Command) error {
reqNum := c.Args().First()
if reqNum == "" {
return fmt.Errorf("missing NIP number")
}
normalize := func(s string) string {
s = strings.ToLower(s)
s = strings.TrimPrefix(s, "nip-")
s = strings.TrimLeft(s, "0")
if s == "" {
s = "0"
}
return s
}
reqNum = normalize(reqNum)
foundLink := ""
for info := range listnips() {
nipNum := normalize(info.nip)
if nipNum == reqNum {
foundLink = info.link
break
}
}
if foundLink == "" {
return fmt.Errorf("NIP-%s not found", strings.ToUpper(reqNum))
}
url := "https://github.com/nostr-protocol/nips/blob/master/" + foundLink
fmt.Println("Opening " + url)
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
cmd = exec.Command("xdg-open", url)
}
return cmd.Start()
},
},
},
Action: func(ctx context.Context, c *cli.Command) error {
reqNum := c.Args().First()
if reqNum == "" {
// list all NIPs
for info := range listnips() {
stdout(info.nip + ": " + info.desc)
}
return nil
}
normalize := func(s string) string {
s = strings.ToLower(s)
s = strings.TrimPrefix(s, "nip-")
s = strings.TrimLeft(s, "0")
if s == "" {
s = "0"
}
return s
}
reqNum = normalize(reqNum)
var foundLink string
for info := range listnips() {
nipNum := normalize(info.nip)
if nipNum == reqNum {
foundLink = info.link
break
}
}
if foundLink == "" {
return fmt.Errorf("NIP-%s not found", strings.ToUpper(reqNum))
}
// fetch the NIP markdown
url := "https://raw.githubusercontent.com/nostr-protocol/nips/master/" + foundLink
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to fetch NIP: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read NIP: %w", err)
}
// render markdown
rendered, err := glamour.Render(string(body), "auto")
if err != nil {
return fmt.Errorf("failed to render markdown: %w", err)
}
fmt.Print(rendered)
return nil
},
}
func listnips() <-chan nipInfo {
ch := make(chan nipInfo)
go func() {
defer close(ch)
resp, err := http.Get("https://raw.githubusercontent.com/nostr-protocol/nips/master/README.md")
if err != nil {
// TODO: handle error? but since chan, maybe send error somehow, but for now, just close
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
bodyStr := string(body)
epoch := strings.Index(bodyStr, "## List")
if epoch == -1 {
return
}
lines := strings.SplitSeq(bodyStr[epoch+8:], "\n")
for line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "##") {
break
}
if !strings.HasPrefix(line, "- [NIP-") {
continue
}
start := strings.Index(line, "[")
end := strings.Index(line, "]")
if start == -1 || end == -1 || end < start {
continue
}
content := line[start+1 : end]
parts := strings.SplitN(content, ":", 2)
if len(parts) != 2 {
continue
}
nipPart := parts[0]
descPart := parts[1]
rest := line[end+1:]
linkStart := strings.Index(rest, "(")
linkEnd := strings.Index(rest, ")")
link := ""
if linkStart != -1 && linkEnd != -1 && linkEnd > linkStart {
link = rest[linkStart+1 : linkEnd]
}
ch <- nipInfo{nipPart, strings.TrimSpace(descPart), link}
}
}()
return ch
}

View File

@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk"
"fiatjaf.com/nostr/sdk/hints/bbolth"
"github.com/fatih/color"
@@ -82,7 +81,7 @@ var outbox = &cli.Command{
return fmt.Errorf("expected exactly one argument (pubkey)")
}
pk, err := nostr.PubKeyFromHex(c.Args().First())
pk, err := parsePubKey(c.Args().First())
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err)
}

110
req.go
View File

@@ -1,14 +1,19 @@
package main
import (
"bufio"
"context"
"fmt"
"math"
"os"
"slices"
"strings"
"sync"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/eventstore/wrappers"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip77"
"github.com/fatih/color"
@@ -38,6 +43,11 @@ example:
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
@@ -53,7 +63,7 @@ example:
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-number",
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
@@ -90,6 +100,13 @@ example:
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
if negentropy {
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
return fmt.Errorf("negentropy is incompatible with --stream, --outbox or --paginate")
}
}
if c.Bool("paginate") && c.Bool("stream") {
return fmt.Errorf("incompatible flags --paginate and --stream")
}
@@ -99,7 +116,7 @@ example:
}
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
if len(relayUrls) > 0 && !negentropy {
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
@@ -152,20 +169,60 @@ example:
}
if len(relayUrls) > 0 || c.Bool("outbox") {
if c.Bool("ids-only") {
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
for _, url := range relayUrls {
ch, err := nip77.FetchIDsOnly(ctx, url, filter)
if negentropy {
store := &slicestore.SliceStore{}
store.Init()
if syncFile := c.String("only-missing"); syncFile != "" {
file, err := os.Open(syncFile)
if err != nil {
log("negentropy call to %s failed: %s", url, err)
continue
return fmt.Errorf("failed to open sync file: %w", err)
}
for id := range ch {
if _, ok := seen[id]; ok {
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
for scanner.Scan() {
var evt nostr.Event
if err := easyjson.Unmarshal([]byte(scanner.Text()), &evt); err != nil {
continue
}
seen[id] = struct{}{}
stdout(id)
if err := store.SaveEvent(evt); err != nil || err == eventstore.ErrDupEvent {
continue
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read sync file: %w", err)
}
}
target := PrintingQuerierPublisher{
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
}
var source nostr.Querier = nil
if c.IsSet("only-missing") {
source = target
}
handle := nip77.SyncEventsFromIDs
if c.Bool("ids-only") {
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
handle = func(ctx context.Context, dir nip77.Direction) {
for id := range dir.Items {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
stdout(id.Hex())
}
}
}
for _, url := range relayUrls {
err := nip77.NegentropySync(ctx, url, filter, source, target, handle)
if err != nil {
log("negentropy sync from %s failed: %s", url, err)
}
}
} else {
@@ -194,7 +251,7 @@ example:
mu := sync.Mutex{}
for _, pubkey := range filter.Authors {
errg.Go(func() error {
n := int(c.Uint("outbox-relays-number"))
n := int(c.Uint("outbox-relays-per-pubkey"))
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already hardcoded, ignore
@@ -276,13 +333,13 @@ var reqFilterFlags = []cli.Flag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&IDSliceFlag{
Name: "id",
Aliases: []string{"i"},
Usage: "only accept events with these ids (hex)",
Usage: "only accept events with these ids",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
@@ -354,19 +411,19 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", dtag})
tags = append(tags, []string{"d", decodeTagValue(dtag)})
}
if len(tags) > 0 && filter.Tags == nil {
@@ -395,3 +452,18 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
return nil
}
type PrintingQuerierPublisher struct {
nostr.QuerierPublisher
}
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
stdout(evt)
return nil
} else if err == eventstore.ErrDupEvent {
return nil
} else {
return err
}
}

120
serve.go
View File

@@ -2,17 +2,24 @@ package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sync/atomic"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/khatru"
"fiatjaf.com/nostr/khatru/blossom"
"fiatjaf.com/nostr/khatru/grasp"
"github.com/bep/debounce"
"github.com/fatih/color"
"github.com/puzpuzpuz/xsync/v3"
"github.com/urfave/cli/v3"
)
@@ -36,10 +43,25 @@ var serve = &cli.Command{
Usage: "file containing the initial batch of events that will be served by the relay as newline-separated JSON (jsonl)",
DefaultText: "the relay will start empty",
},
&cli.BoolFlag{
Name: "negentropy",
Usage: "enable negentropy syncing",
},
&cli.BoolFlag{
Name: "grasp",
Usage: "enable grasp server",
},
&cli.BoolFlag{
Name: "blossom",
Usage: "enable blossom server",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
db := &slicestore.SliceStore{}
var blobStore *xsync.MapOf[string, []byte]
var repoDir string
var scanner *bufio.Scanner
if path := c.String("events"); path != "" {
f, err := os.Open(path)
@@ -71,7 +93,11 @@ var serve = &cli.Command{
rl.Info.Software = "https://github.com/fiatjaf/nak"
rl.Info.Version = version
rl.UseEventstore(db, 1_000_000)
rl.UseEventstore(db, 500)
if c.Bool("negentropy") {
rl.Negentropy = true
}
started := make(chan bool)
exited := make(chan error)
@@ -79,16 +105,67 @@ var serve = &cli.Command{
hostname := c.String("hostname")
port := int(c.Uint("port"))
var printStatus func()
if c.Bool("blossom") {
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
bs.Store = blossom.NewMemoryBlobIndex()
blobStore = xsync.NewMapOf[string, []byte]()
bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
blobStore.Store(sha256+ext, body)
log(" got %s %s\n", color.GreenString("blob stored"), sha256+ext)
printStatus()
return nil
}
bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) {
if body, ok := blobStore.Load(sha256 + ext); ok {
log(" got %s %s\n", color.BlueString("blob downloaded"), sha256+ext)
printStatus()
return bytes.NewReader(body), nil, nil
}
return nil, nil, nil
}
bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error {
blobStore.Delete(sha256 + ext)
log(" got %s %s\n", color.RedString("blob deleted"), sha256+ext)
printStatus()
return nil
}
}
if c.Bool("grasp") {
var err error
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
if err != nil {
return fmt.Errorf("failed to create grasp repos directory: %w", err)
}
g := grasp.New(rl, repoDir)
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
log(" got %s %s %s\n", color.CyanString("git read"), pubkey.Hex(), repo)
printStatus()
return false, ""
}
g.OnWrite = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
log(" got %s %s %s\n", color.YellowString("git write"), pubkey.Hex(), repo)
printStatus()
return false, ""
}
}
go func() {
err := rl.Start(hostname, port, started)
exited <- err
}()
var printStatus func()
// relay logging
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiYellowString("request"), colors.italic(filter))
negentropy := ""
if khatru.IsNegentropySession(ctx) {
negentropy = color.HiBlueString("negentropy ")
}
log(" got %s%s %v\n", negentropy, color.HiYellowString("request"), colors.italic(filter))
printStatus()
return false, ""
}
@@ -123,9 +200,36 @@ var serve = &cli.Command{
}
subs := rl.GetListeningFilters()
log(" %s events: %s, connections: %s, subscriptions: %s\n",
blossomMsg := ""
if c.Bool("blossom") {
blobsStored := blobStore.Size()
blossomMsg = fmt.Sprintf("blobs: %s, ",
color.HiMagentaString("%d", blobsStored),
)
}
graspMsg := ""
if c.Bool("grasp") {
gitAnnounced := 0
gitStored := 0
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
gitAnnounced++
identifier := evt.Tags.GetD()
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
gitStored++
}
}
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
color.HiMagentaString("%d", gitAnnounced),
color.HiMagentaString("%d", gitStored),
)
}
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
color.HiMagentaString("•"),
color.HiMagentaString("%d", totalEvents),
blossomMsg,
graspMsg,
color.HiMagentaString("%d", totalConnections.Load()),
color.HiMagentaString("%d", len(subs)),
)
@@ -133,7 +237,11 @@ var serve = &cli.Command{
}
<-started
log("%s relay running at %s\n", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
log("%s relay running at %s", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
if c.Bool("grasp") {
log(" (grasp repos at %s)", repoDir)
}
log("\n")
return <-exited
},

464
sync.go Normal file
View File

@@ -0,0 +1,464 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"sync"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip77"
"fiatjaf.com/nostr/nip77/negentropy"
"fiatjaf.com/nostr/nip77/negentropy/storage"
"github.com/urfave/cli/v3"
)
var syncCmd = &cli.Command{
Name: "sync",
Usage: "sync events between two relays using negentropy",
Description: `uses nip77 negentropy to sync events between two relays`,
ArgsUsage: "<relay1> <relay2>",
Flags: reqFilterFlags,
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) != 2 {
return fmt.Errorf("need exactly two relay URLs: source and target")
}
filter := nostr.Filter{}
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
peerA, err := NewRelayThirdPartyRemote(ctx, args[0])
if err != nil {
return fmt.Errorf("error setting up %s: %w", args[0], err)
}
peerB, err := NewRelayThirdPartyRemote(ctx, args[1])
if err != nil {
return fmt.Errorf("error setting up %s: %w", args[1], err)
}
tpn := NewThirdPartyNegentropy(
peerA,
peerB,
filter,
)
wg := sync.WaitGroup{}
wg.Go(func() {
err = tpn.Run(ctx)
})
wg.Go(func() {
type op struct {
src *nostr.Relay
dst *nostr.Relay
ids []nostr.ID
}
pending := []op{
{peerA.relay, peerB.relay, make([]nostr.ID, 0, 30)},
{peerB.relay, peerA.relay, make([]nostr.ID, 0, 30)},
}
for delta := range tpn.Deltas {
have := delta.Have.relay
havenot := delta.HaveNot.relay
logverbose("%s has %s, %s doesn't.\n", have.URL, delta.ID.Hex(), havenot.URL)
idx := 0 // peerA
if have == peerB.relay {
idx = 1 // peerB
}
pending[idx].ids = append(pending[idx].ids, delta.ID)
// every 30 ids do a fetch-and-publish
if len(pending[idx].ids) == 30 {
for evt := range pending[idx].src.QueryEvents(nostr.Filter{IDs: pending[idx].ids}) {
pending[idx].dst.Publish(ctx, evt)
}
pending[idx].ids = pending[idx].ids[:0]
}
}
// do it for the remaining ids
for _, op := range pending {
if len(op.ids) > 0 {
for evt := range op.src.QueryEvents(nostr.Filter{IDs: op.ids}) {
op.dst.Publish(ctx, evt)
}
}
}
})
wg.Wait()
return err
},
}
type ThirdPartyNegentropy struct {
PeerA *RelayThirdPartyRemote
PeerB *RelayThirdPartyRemote
Filter nostr.Filter
Deltas chan Delta
}
type Delta struct {
ID nostr.ID
Have *RelayThirdPartyRemote
HaveNot *RelayThirdPartyRemote
}
type boundKey string
func getBoundKey(b negentropy.Bound) boundKey {
return boundKey(fmt.Sprintf("%d:%x", b.Timestamp, b.IDPrefix))
}
type RelayThirdPartyRemote struct {
relay *nostr.Relay
messages chan string
err error
}
func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdPartyRemote, error) {
rtpr := &RelayThirdPartyRemote{
messages: make(chan string, 3),
}
var err error
rtpr.relay, err = nostr.RelayConnect(ctx, url, nostr.RelayOptions{
CustomHandler: func(data string) {
envelope := nip77.ParseNegMessage(data)
if envelope == nil {
return
}
switch env := envelope.(type) {
case *nip77.OpenEnvelope, *nip77.CloseEnvelope:
rtpr.err = fmt.Errorf("unexpected %s received from relay", env.Label())
return
case *nip77.ErrorEnvelope:
rtpr.err = fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason)
return
case *nip77.MessageEnvelope:
rtpr.messages <- env.Message
}
},
})
if err != nil {
return nil, err
}
return rtpr, nil
}
func (rtpr *RelayThirdPartyRemote) SendInitialMessage(filter nostr.Filter, msg string) error {
msgj, _ := json.Marshal(nip77.OpenEnvelope{
SubscriptionID: "sync3",
Filter: filter,
Message: msg,
})
return rtpr.relay.WriteWithError(msgj)
}
func (rtpr *RelayThirdPartyRemote) SendMessage(msg string) error {
msgj, _ := json.Marshal(nip77.MessageEnvelope{
SubscriptionID: "sync3",
Message: msg,
})
return rtpr.relay.WriteWithError(msgj)
}
func (rtpr *RelayThirdPartyRemote) SendClose() error {
msgj, _ := json.Marshal(nip77.CloseEnvelope{
SubscriptionID: "sync3",
})
return rtpr.relay.WriteWithError(msgj)
}
var thirdPartyRemoteEndOfMessages = errors.New("the-end")
func (rtpr *RelayThirdPartyRemote) Receive() (string, error) {
if rtpr.err != nil {
return "", rtpr.err
}
if msg, ok := <-rtpr.messages; ok {
return msg, nil
}
return "", thirdPartyRemoteEndOfMessages
}
func NewThirdPartyNegentropy(peerA, peerB *RelayThirdPartyRemote, filter nostr.Filter) *ThirdPartyNegentropy {
return &ThirdPartyNegentropy{
PeerA: peerA,
PeerB: peerB,
Filter: filter,
Deltas: make(chan Delta, 100),
}
}
func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
peerAIds := make(map[nostr.ID]struct{})
peerBIds := make(map[nostr.ID]struct{})
peerASkippedBounds := make(map[boundKey]struct{})
peerBSkippedBounds := make(map[boundKey]struct{})
// send an empty message to A to start things up
initialMsg := createInitialMessage()
err := n.PeerA.SendInitialMessage(n.Filter, initialMsg)
if err != nil {
return err
}
hasSentInitialMessageToB := false
for {
// receive message from A
msgA, err := n.PeerA.Receive()
if err != nil {
return err
}
msgAb, _ := nostr.HexDecodeString(msgA)
if len(msgAb) == 1 {
break
}
msgToB, err := parseMessageBuildNext(
msgA,
peerBSkippedBounds,
func(id nostr.ID) {
if _, exists := peerBIds[id]; exists {
delete(peerBIds, id)
} else {
peerAIds[id] = struct{}{}
}
},
func(boundKey boundKey) {
peerASkippedBounds[boundKey] = struct{}{}
},
)
if err != nil {
return err
}
// emit deltas from B after receiving message from A
for id := range peerBIds {
select {
case n.Deltas <- Delta{ID: id, Have: n.PeerB, HaveNot: n.PeerA}:
case <-ctx.Done():
return context.Cause(ctx)
}
delete(peerBIds, id)
}
if len(msgToB) == 2 {
// exit condition (no more messages to send)
break
}
// send message to B
if hasSentInitialMessageToB {
err = n.PeerB.SendMessage(msgToB)
} else {
err = n.PeerB.SendInitialMessage(n.Filter, msgToB)
hasSentInitialMessageToB = true
}
if err != nil {
return err
}
// receive message from B
msgB, err := n.PeerB.Receive()
if err != nil {
return err
}
msgBb, _ := nostr.HexDecodeString(msgB)
if len(msgBb) == 1 {
break
}
msgToA, err := parseMessageBuildNext(
msgB,
peerASkippedBounds,
func(id nostr.ID) {
if _, exists := peerAIds[id]; exists {
delete(peerAIds, id)
} else {
peerBIds[id] = struct{}{}
}
},
func(boundKey boundKey) {
peerBSkippedBounds[boundKey] = struct{}{}
},
)
if err != nil {
return err
}
// emit deltas from A after receiving message from B
for id := range peerAIds {
select {
case n.Deltas <- Delta{ID: id, Have: n.PeerA, HaveNot: n.PeerB}:
case <-ctx.Done():
return context.Cause(ctx)
}
delete(peerAIds, id)
}
if len(msgToA) == 2 {
// exit condition (no more messages to send)
break
}
// send message to A
err = n.PeerA.SendMessage(msgToA)
if err != nil {
return err
}
}
// emit remaining deltas before exit
for id := range peerAIds {
select {
case n.Deltas <- Delta{ID: id, Have: n.PeerA, HaveNot: n.PeerB}:
case <-ctx.Done():
return context.Cause(ctx)
}
}
for id := range peerBIds {
select {
case n.Deltas <- Delta{ID: id, Have: n.PeerB, HaveNot: n.PeerA}:
case <-ctx.Done():
return context.Cause(ctx)
}
}
n.PeerA.SendClose()
n.PeerB.SendClose()
close(n.Deltas)
return nil
}
func createInitialMessage() string {
output := bytes.NewBuffer(make([]byte, 0, 64))
output.WriteByte(negentropy.ProtocolVersion)
dummy := negentropy.BoundWriter{}
dummy.WriteBound(output, negentropy.InfiniteBound)
output.WriteByte(byte(negentropy.FingerprintMode))
// hardcoded random fingerprint
fingerprint := [negentropy.FingerprintSize]byte{
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
}
output.Write(fingerprint[:])
return nostr.HexEncodeToString(output.Bytes())
}
func parseMessageBuildNext(
msg string,
skippedBounds map[boundKey]struct{},
idCallback func(id nostr.ID),
skipCallback func(boundKey boundKey),
) (string, error) {
msgb, err := nostr.HexDecodeString(msg)
if err != nil {
return "", err
}
br := &negentropy.BoundReader{}
bw := &negentropy.BoundWriter{}
nextMsg := bytes.NewBuffer(make([]byte, 0, len(msgb)))
acc := &storage.Accumulator{} // this will be used for building our own fingerprints and also as a placeholder
reader := bytes.NewReader(msgb)
pv, err := reader.ReadByte()
if err != nil {
return "", err
}
if pv != negentropy.ProtocolVersion {
return "", fmt.Errorf("unsupported protocol version %v", pv)
}
nextMsg.WriteByte(pv)
for reader.Len() > 0 {
bound, err := br.ReadBound(reader)
if err != nil {
return "", err
}
modeVal, err := negentropy.ReadVarInt(reader)
if err != nil {
return "", err
}
mode := negentropy.Mode(modeVal)
switch mode {
case negentropy.SkipMode:
skipCallback(getBoundKey(bound))
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, int(negentropy.SkipMode))
}
case negentropy.FingerprintMode:
_, err = reader.Read(acc.Buf[0:negentropy.FingerprintSize] /* use this buffer as a dummy */)
if err != nil {
return "", err
}
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
nextMsg.Write(acc.Buf[0:negentropy.FingerprintSize] /* idem */)
}
case negentropy.IdListMode:
// when receiving an idlist we will never send this bound again to this peer
skipCallback(getBoundKey(bound))
// and instead of sending these ids to the other peer we'll send a fingerprint
acc.Reset()
numIds, err := negentropy.ReadVarInt(reader)
if err != nil {
return "", err
}
for range numIds {
id := nostr.ID{}
_, err = reader.Read(id[:])
if err != nil {
return "", err
}
idCallback(id)
acc.AddBytes(id[:])
}
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
fingerprint := acc.GetFingerprint(numIds)
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
nextMsg.Write(fingerprint[:])
}
default:
return "", fmt.Errorf("unknown mode %v", mode)
}
}
return nostr.HexEncodeToString(nextMsg.Bytes()), nil
}