Compare commits

...

256 Commits

Author SHA1 Message Date
Yasuhiro Matsumoto
9df857a076 group chat: handle events synchronously in select, drop goroutine and mutex 2026-06-04 21:41:11 +09:00
alltheseas
52cf03d423 gift unwrap: override rumor pubkey from seal and recompute ID
NIP-17 requires clients verify that the kind:14 pubkey matches
the kind:13 pubkey to prevent sender impersonation. The CLI
gift unwrap path outputs the rumor as-is without setting the
pubkey from the verified seal or recomputing the event ID.

This mirrors nip59.GiftUnwrap() which does both operations.
The override is placed after the ZeroID sentinel check to
preserve decryption failure detection.

Fixes https://github.com/fiatjaf/nak/issues/110

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 09:16:03 -03:00
alltheseas
d0c291b25c gift unwrap: verify seal signature before trusting pubkey
The CLI gift unwrap path skips seal signature verification that
nip59.GiftUnwrap() performs. Without this check, a forged seal
with an arbitrary pubkey would be trusted for decryption key
lookup and sender identity.

Fixes https://github.com/fiatjaf/nak/issues/110

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 09:16:03 -03:00
mattn
6ee60cb197 Merge pull request #139 from mattn/fix/count-auth-handler-consistency
count: use Pool.AuthRequiredHandler to match other commands
2026-06-04 01:56:59 +00:00
mattn
c2eb2d7bdd Merge pull request #138 from mattn/fix/req-sync-save-error
req: propagate non-duplicate save errors when loading sync file
2026-06-04 01:56:34 +00:00
Yasuhiro Matsumoto
168a182d50 count: use Pool.AuthRequiredHandler to match other commands. 2026-06-04 10:55:14 +09:00
Yasuhiro Matsumoto
f202f038a4 req: propagate non-duplicate save errors when loading sync file. 2026-06-04 10:52:46 +09:00
mattn
1043b2a7af Merge pull request #137 from mattn/fix/req-tag-decode-dead-code
req: actually decode bech32 values for single-letter --tag flags
2026-06-04 01:51:56 +00:00
Yasuhiro Matsumoto
9c3b4b69b6 req: actually decode bech32 values for single-letter --tag flags. 2026-06-04 10:50:53 +09:00
mattn
1c542deb5e Merge pull request #136 from mattn/fix/group-chat-race
group: fix data race and out-of-bounds panic in chat command
2026-06-04 01:50:08 +00:00
Yasuhiro Matsumoto
cefc0b91d0 group: fix data race and out-of-bounds panic in chat command. 2026-06-04 09:49:48 +09:00
mattn
8c97c7becb Merge pull request #135 from mattn/refactor/lmdb-early-return
lmdb: invert configPath check to flatten setupLocalDatabases
2026-06-04 00:49:14 +00:00
mattn
ada938c3a8 Merge pull request #134 from mattn/refactor/blossom-mirror-early-return
blossom: drop unnecessary else after early return in mirror command
2026-06-04 00:48:35 +00:00
mattn
c2d74b846e Merge pull request #133 from mattn/refactor/git-init-flatten-nesting
git: flatten nested ifs in ngit remote migration with guard clauses
2026-06-04 00:48:07 +00:00
Yasuhiro Matsumoto
06bd1ddb11 lmdb: invert configPath check to flatten setupLocalDatabases. 2026-06-04 09:48:03 +09:00
Yasuhiro Matsumoto
ebd93964de blossom: drop unnecessary else after early return in mirror command. 2026-06-04 09:21:52 +09:00
Yasuhiro Matsumoto
a285ac6723 git: flatten nested ifs in ngit remote migration with guard clauses. 2026-06-04 09:20:14 +09:00
fiatjaf
e2f791ab85 fetch: support --auth. 2026-06-02 21:35:58 -03:00
mattn
ed5af14305 Merge pull request #130 from fiatjaf/go-tty-v2
go-tty: bump to v2.
2026-05-29 15:30:27 +00:00
Yasuhiro Matsumoto
e5f4b07c3d go-tty: bump to v2. 2026-05-30 00:29:51 +09:00
fiatjaf
b41260f663 --outbox help text better. 2026-05-26 19:51:18 -03:00
fiatjaf
c9e1511865 bunker: fix relay handling, don't mix custom with main relay URLs. 2026-05-21 20:25:07 -03:00
fiatjaf
5af1d111ba key-combine: support parsing npubs and nprofiles. 2026-05-20 16:14:11 -03:00
fiatjaf
2922a0de25 git: support nostr://... URLs without requiring the <relay> component. 2026-05-17 17:10:29 -03:00
fiatjaf
35d628ef6d bunker: fix concurrent map writes (url.Values is a map). 2026-05-16 23:16:30 -03:00
fiatjaf
a2f109c8c8 encode: support reading pointer data as json from stdin also in specific subcommands. 2026-05-16 19:32:15 -03:00
fiatjaf
d9cc9f1d80 encode: fix reading pointer objects as json from stdin. 2026-05-16 14:43:38 -03:00
fiatjaf
d97937eaa4 encode: correctly fail on unsupported modes. 2026-05-16 14:19:42 -03:00
fiatjaf
bdd3aa1093 bunker: fix waiting for slow relays? 2026-05-15 17:27:18 -03:00
fiatjaf
02c12cb671 decode: fix missing identifiers in printed json. 2026-05-08 12:53:42 -03:00
fiatjaf
c04add227d fix serve never starting. 2026-05-05 11:54:09 -03:00
mattn
c3f1ef7877 Merge pull request #126 from mattn/fix/blossom-hash-decode
blossom: fix wrong source length in hex decode of download hash
2026-05-05 21:41:38 +09:00
mattn
d0e719fb93 Merge pull request #128 from mattn/fix/nsite-path-traversal
nsite: fix path traversal vulnerability in download command
2026-05-05 21:39:55 +09:00
Yasuhiro Matsumoto
187842214e nsite: fix path traversal vulnerability in download command. 2026-05-05 21:36:46 +09:00
mattn
405be6efa9 Merge pull request #127 from mattn/fix/publish-reply-nil-panic
publish: avoid nil panic when --reply decodes to an unexpected type
2026-05-05 21:18:10 +09:00
Yasuhiro Matsumoto
47e462fe55 publish: avoid nil panic when --reply decodes to an unexpected type. 2026-05-05 21:15:00 +09:00
Yasuhiro Matsumoto
99ef7a8c91 blossom: fix wrong source length in hex decode of download hash. 2026-05-05 21:08:55 +09:00
fiatjaf
a152d7f633 verify: debug mode (-vv) prints useful info about event serialization. 2026-04-23 22:26:52 -03:00
fiatjaf
25d2045640 event: --no-verify flag. 2026-04-23 20:54:58 -03:00
fiatjaf
09b9939956 nsite command. 2026-04-23 20:50:03 -03:00
fiatjaf
2a8cd898c2 allow --auth on 'group'. 2026-04-19 20:37:41 -03:00
fiatjaf
eae828e03b update to the fact that Pool doesn't use PoolOptions anymore, just modify the global pool directly. 2026-04-19 20:17:00 -03:00
fiatjaf
9ae5798a24 print event in 'validate' and 'filter', apply jq on 'filter' and 'req' with gojq. 2026-04-19 19:28:34 -03:00
fiatjaf
5930437578 nak validate 2026-04-18 19:15:51 -03:00
fiatjaf
2193491c3b encode: support naddr for replaceable events and improve kind and d-tag validation..
fixes https://github.com/fiatjaf/nak/issues/122

honestly I don't know why you want to naddr-encode replaceable events, that sounds like an awful idea, but what can I do.
2026-04-18 15:07:27 -03:00
fiatjaf
d3f4548dbd why were we recreating the pool? 2026-04-18 11:13:08 -03:00
fiatjaf
07b791e90a adapt to some nostrlib changes. 2026-04-18 09:19:43 -03:00
fiatjaf
2c3d72b337 nak key validate. 2026-04-10 09:31:51 -03:00
Ren Amamiya
46c68462db git.shakespeare.diy not git.shakespeare.dyi 2026-04-09 11:49:34 -03:00
fiatjaf
0834420d89 replace %w with %s in lineProcessingError(). 2026-04-05 18:24:59 -03:00
fiatjaf
217352ed0b blossom: take multiple --server flags. 2026-04-05 17:12:01 -03:00
fiatjaf
318838b3ff key: a command for expanding partial keys by left-padding with zeroes. 2026-04-03 01:36:08 -03:00
fiatjaf
b811396d8b fetch: exit with error if event is not fetched. 2026-04-02 03:59:45 -03:00
fiatjaf
2bc1cf3417 performReq now uses "read" relays from p/P-tags when no authors are specified in outbox mode. 2026-04-02 03:46:54 -03:00
fiatjaf
65ddf7b821 use appendUnique from nostrlib. 2026-04-02 03:24:05 -03:00
fiatjaf
e19306b124 event: --outbox flag for publishing. 2026-04-02 03:14:19 -03:00
fiatjaf
e059fba487 event/req: accept -h tag and addresses in -a tag. 2026-03-30 12:20:52 -03:00
fiatjaf
00f44958de git: take npub, nip05 or hex for user fields on "init". 2026-03-30 10:03:35 -03:00
fiatjaf
e37ea23a8d groups: stop appending undefined tags to edit-metadata. 2026-03-30 08:56:24 -03:00
fiatjaf
3c4cb533b4 groups: create-group. 2026-03-30 08:56:24 -03:00
fiatjaf
be26dfca3c print "connecting to..." message without the leading scheme. 2026-03-30 08:56:24 -03:00
mattn
26518ec260 Merge pull request #119 from fiatjaf/fix/curl-args-oob
fix: prevent out-of-bounds panic in curl flag parsing
2026-03-30 15:09:52 +09:00
mattn
de599c64f6 Merge pull request #118 from fiatjaf/fix/event-split-safety
fix: use strings.Cut instead of strings.Split to avoid potential panic
2026-03-30 14:10:25 +09:00
Yasuhiro Matsumoto
3d650ee502 fix: use strings.Cut instead of strings.Split to avoid potential panic
strings.Split(url, "://")[1] panics if the URL has no "://" separator.
strings.Cut handles this safely by returning an empty string.
2026-03-30 13:43:29 +09:00
Yasuhiro Matsumoto
67920ccbdd fix: prevent out-of-bounds panic in curl flag parsing
When a flag expecting a value (e.g. --sec) is the last argument,
the code incremented the index without checking if the next
argument exists, causing a potential panic on os.Args access.
2026-03-30 13:43:08 +09:00
fiatjaf
0dbe14aa93 git: 'download' command for downloading individual files. 2026-03-26 17:46:47 -03:00
fiatjaf
ec1721cfe3 count: support CLOSED and AUTH.
fixes https://github.com/fiatjaf/nak/issues/117
2026-03-25 21:42:11 -03:00
fiatjaf
3b318fec54 event: must clear events before reusing. 2026-03-24 22:34:10 -03:00
fiatjaf
82275e4b9e when parsing a nip05 as a pubkey there is no point in falling back to parsing as hex or npub. 2026-03-22 17:17:28 -03:00
fiatjaf
ae98e99ec6 update nostrlib to fix stuck subscriptions. 2026-03-20 20:56:38 -03:00
Yasuhiro Matsumoto
29db04b7ea fix: add missing target flag to nutzap command 2026-03-20 13:44:14 -03:00
fiatjaf
59fecacbdc ensure we're adding a "K" tag to kind:1111 events. 2026-03-17 20:48:53 -03:00
fiatjaf
faac4d9440 ensure an event is not resigned if it was already signed and wasn't changed. 2026-03-17 19:32:47 -03:00
fiatjaf
f00a4a7d2a fix potential double-yield() usage on reading lines from stdin. 2026-03-17 14:57:42 -03:00
fiatjaf
d823ac55a6 fix nostrlib dependency. 2026-03-17 14:57:11 -03:00
fiatjaf
105ccef7ec gift: fix potential keys ordering and rumor decryption failure check. 2026-03-17 14:56:55 -03:00
fiatjaf
9d4df21836 gift/encrypt: fix wording and yield returns. 2026-03-17 11:49:09 -03:00
fiatjaf
952d638ac3 git: fix --owner flag in "init".
fixes https://github.com/fiatjaf/nak/issues/113
2026-03-16 16:09:00 -03:00
fiatjaf
c1e72e0af1 convert tags to appropriate hex pubkeys, ids etc, even from nip05, but check the tag name first for better (hardcoded) context. 2026-03-13 00:30:46 -03:00
fiatjaf
5f4efdbc69 'group forum' with the ui like 'nak git issue'. 2026-03-12 19:03:20 -03:00
fiatjaf
4967db13a1 update nostrlib dependency. 2026-03-12 16:50:19 -03:00
fiatjaf
da0b753371 group: edit-metadata as PUT, not PATCH. 2026-03-12 16:33:50 -03:00
fiatjaf
bef67d35d2 groups: replace no-text with supported_kinds support. 2026-03-12 16:20:23 -03:00
fiatjaf
a8fb2e4189 git: allow closing a patch as applied. 2026-03-12 16:19:58 -03:00
fiatjaf
7aea4cf9a1 git: ensure some operations are only completed if you're a maintainer. 2026-03-08 21:24:28 -03:00
fiatjaf
d472efe707 git: issue and patch closing. 2026-03-08 21:06:34 -03:00
fiatjaf
17341b3af6 git: issue and patch listing unified in base command. 2026-03-08 21:02:52 -03:00
fiatjaf
ff5a7b4ba7 git: issue replying in the middle of a thread. 2026-03-08 18:29:53 -03:00
mattn
7596e317b8 Merge pull request #109 from fiatjaf/fix/group-forum-tag-filter
fix: remove incorrect '#' prefix from TagMap key in group forum filter
2026-03-08 11:24:24 +09:00
mattn
da7fc3fac3 Merge pull request #108 from mattn/fix/bunker-format-verbs
fix: use %s instead of %w in log/color.RedString format strings
2026-03-08 11:22:48 +09:00
mattn
0e8475b388 Merge pull request #107 from mattn/fix/wallet-typo
fix typo
2026-03-08 11:19:04 +09:00
Yasuhiro Matsumoto
5532c884bc fix: use %s instead of %w in log/color.RedString format strings
%w is only valid in fmt.Errorf. When used in fmt.Sprintf (via
color.RedString) or log, it produces garbled output like
"%!w(MISSING)" and the actual error is not displayed.
2026-03-08 11:15:14 +09:00
Yasuhiro Matsumoto
05857ab190 fix: typo in wallet error message ("walle" -> "wallet") 2026-03-08 11:14:04 +09:00
Yasuhiro Matsumoto
bf22a63404 fix: remove incorrect '#' prefix from TagMap key in group forum filter
The forum subcommand used "#h" as the TagMap key, but TagMap keys
should not include the '#' prefix. All other TagMap usages in this
file correctly use just the tag letter (e.g. "d", "h").
2026-03-08 11:13:42 +09:00
fiatjaf
6bd2d1cdfc git: issues and patches improved enormously. 2026-03-07 23:10:00 -03:00
fiatjaf
9c8d59c5c5 group: talk prints URL to meet.livekit.io 2026-03-07 23:10:00 -03:00
fiatjaf
5957c08d15 git: issues and patches. 2026-03-06 18:40:54 -03:00
fiatjaf
f59c8a670d group: editing groups with livekit metadata and getting jwt from the server (can be used with meet.livekit.io for now). 2026-03-05 22:49:23 -03:00
fiatjaf
61a3b89d08 req: handle values from closeds better.
(it may be closed when all is ok, so we shouldn't rely on yielding an actual value)
2026-03-04 16:16:46 -03:00
fiatjaf
d61fdc4cb4 git: fix first publishing of a new repository announcement. 2026-03-04 16:01:22 -03:00
fiatjaf
0735ded0fc update nostrlib to hopefully fix issues with zombie connections. 2026-03-04 00:10:20 -03:00
fiatjaf
5233a77510 nak profile 2026-02-23 20:56:21 -03:00
Alex Gleason
fd34cc7c5e git clone: support NIP-05 names in nostr:// URIs
Resolve NIP-05 identifiers (e.g. chad@chadwick.site) in the owner
position of nostr:// URIs, so 'nak git clone nostr://user@domain/relay/id'
works without manually resolving to an npub first.
2026-02-20 23:11:36 -03:00
JannikStreek
2d151c2ac8 Fix: docker build 2026-02-19 10:58:54 -03:00
fiatjaf
037e8efcc6 count: support reading filters from stdin like req. 2026-02-12 14:34:24 -03:00
fiatjaf
1b380dea9a count: support the same flags as req.
fixes https://github.com/fiatjaf/nak/issues/103
2026-02-12 14:25:57 -03:00
fiatjaf
f126b3f7ee mcp: searching for pubkeys and nip05s returns them directly. 2026-02-06 13:52:15 -03:00
fiatjaf
dc5ffe5129 bunker: fix authorizing pubkeys from --authorized-secrets.
closes https://github.com/fiatjaf/nak/issues/102
2026-02-04 23:26:26 -03:00
Yasuhiro Matsumoto
7637b5018f avoid panic when mint URL has no '://' 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
d5ab34bb2f remove redundant if err != nil in main 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
49345333c4 check dateparser error before using date.Time 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
b5de7b78bc prevent panic on AUTH challenge tag (nil or len<2) 2026-02-01 14:24:51 -03:00
fiatjaf
ba9a5badc6 uselessly change some words. 2026-01-27 09:25:34 -03:00
fiatjaf
c0bbf73961 update nostrlib to fix decoding of "note1".
fixes https://github.com/fiatjaf/nak/issues/99
2026-01-26 23:45:12 -03:00
fiatjaf
5320feee4f more installation formulas. 2026-01-26 22:46:36 -03:00
fiatjaf
58c1fab0f0 mcp: search method. 2026-01-26 22:34:33 -03:00
fiatjaf
5f30009e72 decode: print error when failed. 2026-01-26 22:34:33 -03:00
fiatjaf
548918578b add new capabilities to README. 2026-01-26 22:34:33 -03:00
Alex Gleason
7757400ab3 Add easy install script 2026-01-26 22:27:28 -03:00
fiatjaf
3ee6320312 bunker: ignore duplicates caused by switch_relays. 2026-01-21 23:17:00 -03:00
fiatjaf
91474d65eb bunker: set default relays so switch_relays works. 2026-01-21 22:19:00 -03:00
fiatjaf
7d782737c4 git status: fix commit printing. 2026-01-21 14:36:25 -03:00
fiatjaf
9160c68cb5 bunker: using unix sockets. 2026-01-21 14:31:12 -03:00
fiatjaf
bf19f38996 nak bunker connect 'nostrconnect://...' working. 2026-01-21 12:44:40 -03:00
fiatjaf
4e2c136e45 nostrconnect:// beginnings. 2026-01-20 17:19:30 -03:00
fiatjaf
8cef1ed0ea group: publishing moderation actions. 2026-01-20 12:52:00 -03:00
fiatjaf
e05b455a05 group: publishing chat messages. 2026-01-18 23:38:03 -03:00
fiatjaf
9190c9d988 nip29/group command with read-only functionality for now. 2026-01-18 23:18:16 -03:00
fiatjaf
e64ad8f078 git: better printing of server statuses. 2026-01-18 21:44:06 -03:00
fiatjaf
b36718caaa git: status.
and a fix for repository announcements getting updated every time due to time shifts.
2026-01-18 21:32:01 -03:00
fiatjaf
5c658c38f1 bring back old github actions builder. 2026-01-18 14:55:03 -03:00
fiatjaf
2a5ce3b249 blossom mirror to only take a URL and do its thing, not try to list blobs. 2026-01-18 14:47:28 -03:00
fiatjaf
c0b85af734 make cgofuse the default for "fs" only on windows. on linux and mac it needs a "cgofuse" build tag. 2026-01-18 10:54:34 -03:00
Yasuhiro Matsumoto
cb2247c9da implement blossom mirror 2026-01-17 11:07:47 -03:00
mattn
686d960f62 Merge pull request #96 from mattn/fix-release
fix release build
2026-01-17 21:14:28 +09:00
Yasuhiro Matsumoto
af04838153 fix release build 2026-01-17 20:51:01 +09:00
fiatjaf
c6da13649d hopefully eliminate the weird case of cron and githubactions calling nak with an empty stdin and causing it to do nothing.
closes https://github.com/fiatjaf/nak/issues/90
2026-01-16 16:11:07 -03:00
Yasuhiro Matsumoto
acd6227dd0 fix darwin build 2026-01-16 15:17:29 -03:00
mattn
00fbda9af7 use native runner and install macfuse 2026-01-16 13:43:19 -03:00
fiatjaf
e838de9b72 fs: move everything to the top-level directory. 2026-01-16 12:34:09 -03:00
fiatjaf
6dfbed4413 fs: just some renames. 2026-01-16 12:18:32 -03:00
fiatjaf
0e283368ed bunker: authorize preexisting keys first. 2026-01-16 12:15:07 -03:00
mattn
38775e0d93 Use cgofuse (#92) 2026-01-14 22:41:14 -03:00
fiatjaf
fabcad3f61 key: fix stupid error when passing nsec1 code to nak key public. 2026-01-10 09:56:39 -03:00
fiatjaf
69e4895e48 --outbox flag for encode. 2026-01-08 22:15:54 -03:00
fiatjaf
81524de04f gift: unwrap tries both decoupled and identity keys, wrap defaults to decoupled but accepts flags to change that. 2025-12-30 15:28:06 -03:00
fiatjaf
8334474f96 git: add viewsource.win to list of possible web views. 2025-12-29 19:53:07 -03:00
fiatjaf
87f27e214e use dekey by default on gift wrap and unwrap. 2025-12-28 12:49:53 -03:00
fiatjaf
32999917b4 actually run the smoke test. 2025-12-27 14:48:17 -03:00
fiatjaf
a19a179548 dekey: don't publish device announcements when not necessary, delete them when they become unnecessary. 2025-12-24 11:46:43 -03:00
fiatjaf
9b684f2c65 dekey: make it better and fix things, --rotate and other flags, prompts by default etc. 2025-12-24 00:09:50 -03:00
fiatjaf
6d87887855 spell: execute from history using the name, not only the id. 2025-12-23 21:32:54 -03:00
fiatjaf
e9c4deaf6d nak req --spell for creating spells. 2025-12-23 21:31:54 -03:00
fiatjaf
965a312b46 only build with lmdb on linux. 2025-12-22 13:01:26 -03:00
fiatjaf
2e4079f92c freeze nostrlib version. 2025-12-22 12:25:34 -03:00
fiatjaf
5b64795015 git: fix --tags 2025-12-22 12:24:03 -03:00
fiatjaf
5d4fe434c3 spell: also take a --pub to run spells in the context of other users. 2025-12-22 12:11:27 -03:00
fiatjaf
b95665d986 spell: store spells locally and add stdin spell to history. 2025-12-22 11:59:29 -03:00
fiatjaf
3be80c29df spell: fix listing recent. 2025-12-22 00:22:14 -03:00
fiatjaf
e01cfbde47 print spell details before running. 2025-12-22 00:19:10 -03:00
fiatjaf
e91d4429ec switch the local databases to lmdb so they can be accessed by multiple nak instances at the same time. 2025-12-22 00:18:56 -03:00
fiatjaf
21423b4a21 spell: execute from stdin event. 2025-12-21 21:59:44 -03:00
fiatjaf
1b7f3162b5 automatically create hints, store, and kvstore for system always. 2025-12-21 21:58:22 -03:00
fiatjaf
8f38468103 basic grimoire spell support. 2025-12-21 15:11:08 -03:00
fiatjaf
9bf728d850 git: nip34 state as fake heads instead of fake remotes. 2025-12-21 15:11:00 -03:00
fiatjaf
8396738fe2 git: push --tags support. 2025-12-21 15:11:00 -03:00
fiatjaf
c1d1682d6e dekey: add logs. 2025-12-19 14:50:59 -03:00
fiatjaf
6f00ff4c73 bunker: fix a halting waitgroup issue. 2025-12-16 13:13:12 -03:00
fiatjaf
68bbece3db update keypair pee example on readme. 2025-12-16 13:12:56 -03:00
fiatjaf
a83b23d76b add nak git demo to README. 2025-12-05 22:15:08 -03:00
fiatjaf
a288cc47a4 add example of compilation with -tags debug to README. 2025-12-05 22:09:22 -03:00
fiatjaf
5ee7670ba8 req: fix infinite loop when events channel is exhausted. 2025-12-04 13:21:43 -03:00
fiatjaf
b973b476bc req: print CLOSED messages. 2025-12-04 09:24:36 -03:00
fiatjaf
252612b12f add pee trick. 2025-12-04 08:46:20 -03:00
fiatjaf
4b8b6bb3de dekey: nip4e (untested). 2025-12-03 23:08:59 -03:00
fiatjaf
df491be232 serve: --grasp-path (hidden). 2025-12-02 15:53:18 -03:00
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
fiatjaf
2758285d51 update nostrlib. 2025-09-08 11:11:07 -03:00
fiatjaf
ecb7f8f195 event: renew relay connection before publishing if necessary. 2025-09-07 18:56:51 -03:00
fiatjaf
9251702460 query batching on nak req --outbox. 2025-09-06 22:21:11 -03:00
fiatjaf
13452e6916 fix nostrlib dependency. 2025-09-06 07:39:25 -03:00
fiatjaf
cdd64e340f nak req --outbox 2025-09-05 17:12:21 -03:00
fiatjaf
3b4d6046cf nak admin: for nip86 management (the previous command was broken). 2025-09-04 13:04:13 -03:00
fiatjaf
bf1690a041 get rid of badger, replace with bolt, following nostrlib. 2025-09-03 21:37:03 -03:00
fiatjaf
88031c888b wallet --stream 2025-08-29 16:25:41 -03:00
fiatjaf
6f0e777324 wallet tokens drop 2025-08-29 16:25:38 -03:00
fiatjaf
b316646821 new release with updated dependencies. 2025-08-18 21:01:52 -03:00
fiatjaf
d3975679e4 add labels to subscriptions for easier debugging. 2025-08-14 13:28:15 -03:00
fiatjaf
23e27da077 use isatty for detecting stuff for the fancy output (it doesn't work). 2025-08-14 13:28:15 -03:00
fiatjaf
1a221a133c cleanup and fix readme. 2025-08-14 13:28:15 -03:00
George
a698c59b0b fix build on OpenBSD 2025-07-23 16:11:57 -03:00
fiatjaf
87bf5ef446 fix nak blossom list stupid segfault. 2025-07-17 20:00:03 -03:00
fiatjaf
7c58948924 verify: better handling of stdout and verbose logging output.
fixes: https://github.com/fiatjaf/nak/issues/74
2025-07-17 16:14:36 -03:00
fiatjaf
ff02e6890b adapt to nostr lib websocket refactor commit (which includes the filters thing). 2025-07-11 13:02:06 -03:00
fiatjaf
fb377f4775 reword some things. 2025-07-05 11:14:29 -03:00
Anthony Accioly
b1114766e5 docs(readme): update caution note with encryption guidance
- Revise the caution note to include instructions for encrypting private keys using NIP-49.
2025-07-04 14:45:38 -03:00
Anthony Accioly
e0febbf190 docs(readme): remove outdated contributing section
- Delete the outdated contributing section referencing NIP-34.
2025-07-04 14:45:38 -03:00
Anthony Accioly
2d2e657778 docs(readme): caution note on plaintext credential storage
- Add a warning about credentials being stored in plain text when using
`--persist`.
2025-07-04 14:45:38 -03:00
Anthony Accioly
d32654447a feat(bunker): add QR code generation for bunker URI
- Add `--qrcode` flag to display a QR code for the bunker URI.
- Update `README.md` with usage instructions for the new flag.
- Include `qrterminal` dependency for QR code generation.
2025-07-04 14:45:38 -03:00
mplorentz
fea23aecc3 Add Dockerfile
I added this so that I could run a nak bunker on my server alongside my other containers. Thought it might be useful for others.
2025-07-02 23:28:40 -03:00
fiatjaf
cc526acb10 bunker: fix overwriting all keys always with default. 2025-07-01 15:52:44 -03:00
fiatjaf
fd19855543 remove a dangling print statement. 2025-07-01 12:43:04 -03:00
fiatjaf
ecfe3a298e add persisted bunker and filter examples to readme. 2025-07-01 12:42:57 -03:00
fiatjaf
9c5f68a955 bunker: fix handling of provided and stored secret keys. 2025-07-01 12:36:54 -03:00
fiatjaf
0aef173e8b nak bunker --persist/--profile 2025-07-01 11:40:34 -03:00
fiatjaf
6e4a546212 release with fixes. 2025-06-27 16:34:59 -03:00
fiatjaf
55c9d4ee45 remove the bunker context timeout because it causes the entire bunker to disconnect. 2025-06-27 16:28:21 -03:00
fiatjaf
550c89d8d7 slightly improve some error messages. 2025-06-27 13:50:28 -03:00
fiatjaf
1e9be3ed84 nak filter 2025-06-27 13:49:40 -03:00
fiatjaf
79cbc57dde fix main command error handler printing wrongly formatted stuff. 2025-06-27 13:48:07 -03:00
fiatjaf
1e237b4c42 do not fill .Content when "content" is received empty from stdin.
fixes https://github.com/fiatjaf/nak/issues/71
2025-06-23 17:57:45 -03:00
fiatjaf
89ec8b9822 simplify README about $NOSTR_CLIENT_KEY. 2025-06-20 21:06:42 -03:00
Anthony Accioly
fba83ea39e docs(readme): clarify NIP-46 signing with remote bunker
- Add example linking to Amber for NIP-46 bunker usage.
- Include note on setting `NOSTR_CLIENT_KEY`
2025-06-20 21:02:57 -03:00
Anthony Accioly
bd5569955c fix(helpers): add timeout and verbose logging for bunker connection
- Add a 10-second timeout to the bunker connection process using context
- Include detailed verbose logging for debugging.
2025-06-20 21:02:57 -03:00
Rui Chen
35ea2582d8 fix: update go.sum to fix build
Signed-off-by: Rui Chen <rui@chenrui.dev>
2025-06-20 16:24:15 -03:00
fiatjaf
fa63dbfea3 release v0.14.3 2025-06-20 11:06:09 -03:00
fiatjaf
a6509909d0 nostrfs: update pointer thing from nostrlib. 2025-06-08 10:50:08 -03:00
fiatjaf
239dd2d42a add example of recording and publishing a voice note. 2025-05-25 23:34:05 -03:00
fiatjaf
0073c9bdf1 compile tests again. 2025-05-23 07:52:19 -03:00
Chris McCormick
b5bd2aecf6 Run smoke test on release workflow success. 2025-05-23 07:49:54 -03:00
Chris McCormick
f27ac6c0e3 Basic smoke tests. 2025-05-23 07:49:54 -03:00
52 changed files with 11778 additions and 1010 deletions

36
.dockerignore Normal file
View File

@@ -0,0 +1,36 @@
# git files
.git
.gitignore
# documentation
README.md
LICENSE
# development files
justfile
*.md
# test files
*_test.go
cli_test.go
# build artifacts
nak
*.exe
mnt
# ide and editor files
.vscode
.idea
*.swp
*.swo
*~
# os generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

View File

@@ -47,3 +47,93 @@ jobs:
md5sum: false
sha256sum: false
compress_assets: false
smoke-test-linux-amd64:
runs-on: ubuntu-latest
needs:
- build-all-for-all
steps:
- name: download and smoke test latest binary
run: |
set -eo pipefail # exit on error, and on pipe failures
echo "downloading nak binary from releases"
RELEASE_URL="https://api.github.com/repos/fiatjaf/nak/releases/latest"
wget $(wget -q -O - ${RELEASE_URL} | jq -r '.assets[] | select(.name | contains("linux-amd64")) | .browser_download_url') -O nak -nv
chmod +x nak
echo "printing version..."
./nak --version
# generate and manipulate keys
echo "testing key operations..."
SECRET_KEY=$(./nak key generate)
PUBLIC_KEY=$(echo $SECRET_KEY | ./nak key public)
echo "generated key pair: $SECRET_KEY => $PUBLIC_KEY"
# create events
echo "testing event creation..."
./nak event -c "hello world"
HELLOWORLD=$(./nak event -c "hello world")
echo " hello world again: $HELLOWORLD"
./nak event --ts "2 days ago" -c "event with timestamp"
./nak event -k 1 -t "t=test" -c "event with tag"
# test NIP-19 encoding/decoding
echo "testing NIP-19 encoding/decoding..."
NSEC=$(echo $SECRET_KEY | ./nak encode nsec)
echo "encoded nsec: $NSEC"
./nak encode npub 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
EVENT_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
NEVENT1=$(./nak encode nevent $EVENT_ID)
echo "encoded nevent1: $NEVENT1"
./nak decode $NEVENT1
./nak decode npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
# test event verification
echo "testing event verification..."
# create an event and verify it
VERIFY_EVENT=$(./nak event -c "verify me")
echo $VERIFY_EVENT | ./nak verify
# test PoW
echo "testing pow..."
./nak event -c "testing pow" --pow 8
# test NIP-49 key encryption/decryption
echo "testing NIP-49 key encryption/decryption..."
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
echo "nip-49 encryption/decryption test failed!"
exit 1
fi
# test multi-value tags
echo "testing multi-value tags..."
./nak event --ts "yesterday" -t "e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.example.com;root" -c "testing multi-value tags"
# test relay operations (with a public relay)
echo "testing publishing..."
# publish a simple event to a public relay
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol < /dev/null)
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
echo "published event ID: $EVENT_ID"
# wait a moment for propagation
sleep 2
# fetch the event we just published
./nak req -i $EVENT_ID nos.lol
# test serving (just start and immediately kill)
echo "testing serve command..."
timeout 2s ./nak serve || true
# test filesystem mount (just start and immediately kill)
echo "testing fs mount command..."
mkdir -p /tmp/nostr-mount
timeout 2s ./nak fs --sec $SECRET_KEY /tmp/nostr-mount || true
echo "all tests passed"

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# build stage
FROM golang:1.25-alpine AS builder
# install git and ca-certificates (needed for fetching dependencies)
RUN apk add --no-cache git ca-certificates gcc musl-dev
# set working directory
WORKDIR /app
# copy go mod files first for better caching
COPY go.mod go.sum ./
# download dependencies
RUN go mod download
# copy source code
COPY . .
# build the application
# use cgo_enabled=0 to create a static binary
# use -ldflags to strip debug info and reduce binary size
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-w -s" -o nak .
# runtime stage
FROM alpine:latest
# install ca-certificates for https requests (needed for relay connections)
RUN apk --no-cache add ca-certificates
# create a non-root user
RUN adduser -D -s /bin/sh nakuser
# set working directory
WORKDIR /home/nakuser
# copy the binary from builder stage
COPY --from=builder /app/nak /usr/local/bin/nak
# make sure the binary is executable
RUN chmod +x /usr/local/bin/nak
# switch to non-root user
USER nakuser
# set the entrypoint
ENTRYPOINT ["nak"]
# default command (show help)
CMD ["--help"]

216
README.md

File diff suppressed because one or more lines are too long

186
admin.go Normal file
View File

@@ -0,0 +1,186 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip86"
"github.com/urfave/cli/v3"
)
var admin = &cli.Command{
Name: "admin",
Usage: "manage relays using the relay management API",
Description: `examples:
nak admin allowpubkey myrelay.com --pubkey 1234... --reason "good user"
nak admin banpubkey myrelay.com --pubkey 1234... --reason "spam"
nak admin listallowedpubkeys myrelay.com
nak admin changerelayname myrelay.com --name "My Relay"`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: (func() []*cli.Command {
methods := []struct {
method string
args []string
}{
{"allowpubkey", []string{"pubkey", "reason"}},
{"banpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"listbannedpubkeys", nil},
{"listeventsneedingmoderation", nil},
{"allowevent", []string{"id", "reason"}},
{"banevent", []string{"id", "reason"}},
{"listbannedevents", nil},
{"changerelayname", []string{"name"}},
{"changerelaydescription", []string{"description"}},
{"changerelayicon", []string{"icon"}},
{"allowkind", []string{"kind"}},
{"disallowkind", []string{"kind"}},
{"listallowedkinds", nil},
{"blockip", []string{"ip", "reason"}},
{"unblockip", []string{"ip", "reason"}},
{"listblockedips", nil},
}
commands := make([]*cli.Command, 0, len(methods))
for _, def := range methods {
def := def
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
for i, argName := range def.args {
flags[i] = declareFlag(argName)
}
cmd := &cli.Command{
Name: def.method,
Usage: fmt.Sprintf(`the "%s" relay management RPC call`, def.method),
Description: fmt.Sprintf(
`the "%s" management RPC call, see https://nips.nostr.com/86 for more information`, def.method),
Flags: flags,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
params := make([]any, len(def.args))
for i, argName := range def.args {
params[i] = getArgument(c, argName)
}
req := nip86.Request{Method: def.method, Params: params}
reqj, _ := json.Marshal(req)
relayUrls := c.Args().Slice()
if len(relayUrls) == 0 {
stdout(string(reqj))
return nil
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
for _, relayUrl := range relayUrls {
httpUrl := "http" + nostr.NormalizeURL(relayUrl)[2:]
log("calling '%s' on %s... ", def.method, httpUrl)
body := bytes.NewBuffer(nil)
body.Write(reqj)
req, err := http.NewRequestWithContext(ctx, "POST", httpUrl, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Authorization
payloadHash := sha256.Sum256(reqj)
tokenEvent := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", httpUrl},
{"method", "POST"},
{"payload", hex.EncodeToString(payloadHash[:])},
},
}
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
return fmt.Errorf("failed to sign token event: %w", err)
}
evtj, _ := json.Marshal(tokenEvent)
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
// Content-Type
req.Header.Set("Content-Type", "application/nostr+json+rpc")
// make request to relay
resp, err := http.DefaultClient.Do(req)
if err != nil {
log("failed: %s\n", err)
continue
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log("failed to read response: %s\n", err)
continue
}
if resp.StatusCode >= 300 {
log("failed with status %d\n", resp.StatusCode)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
var response nip86.Response
if err := json.Unmarshal(b, &response); err != nil {
log("bad json response: %s\n", err)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
resp.Body.Close()
// print the result
log("\n")
pretty, _ := json.MarshalIndent(response, "", " ")
stdout(string(pretty))
}
return nil
},
}
commands = append(commands, cmd)
}
return commands
})(),
}
func declareFlag(argName string) cli.Flag {
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
switch argName {
case "kind":
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
case "reason":
return &cli.StringFlag{Name: argName, Usage: usage}
default:
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
}
}
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "kind":
return c.Int(argName)
default:
return c.String(argName)
}
}

View File

@@ -3,11 +3,12 @@ package main
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"io"
"os"
"unsafe"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/urfave/cli/v3"
@@ -20,7 +21,7 @@ var blossomCmd = &cli.Command{
Usage: "an army knife for blossom things",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
&cli.StringSliceFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "the hostname of the target mediaserver",
@@ -38,11 +39,15 @@ 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(client.GetMediaServer(), keyer.NewReadOnlySigner(pk))
}
servers := c.StringSlice("server")
if err != nil {
return err
}
client = blossom.NewClient(servers[0], keyer.NewReadOnlySigner(pk))
} else {
var err error
client, err = getBlossomClient(ctx, c)
@@ -70,11 +75,17 @@ var blossomCmd = &cli.Command{
DisableSliceFlagSeparator: true,
ArgsUsage: "[files...]",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
servers := c.StringSlice("server")
if len(servers) == 0 {
return fmt.Errorf("no server specified")
}
if isPiped() {
// get file from stdin
if c.Args().Len() > 0 {
@@ -85,34 +96,36 @@ var blossomCmd = &cli.Command{
if err != nil {
return fmt.Errorf("failed to read stdin: %w", err)
}
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
if err != nil {
return err
}
j, _ := json.Marshal(bd)
stdout(string(j))
} else {
// get filenames from arguments
hasError := false
for _, fpath := range c.Args().Slice() {
bd, err := client.UploadFilePath(ctx, fpath)
for _, server := range servers {
client := blossom.NewClient(server, keyer)
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasError = true
ctx = lineProcessingError(ctx, "failed to upload to '%s': %s", server, err)
continue
}
j, _ := json.Marshal(bd)
stdout(string(j))
}
} else {
// get filenames from arguments
for _, fpath := range c.Args().Slice() {
for _, server := range servers {
client := blossom.NewClient(server, keyer)
bd, err := client.UploadFilePath(ctx, fpath)
if err != nil {
ctx = lineProcessingError(ctx, "failed to upload '%s' to '%s': %s", fpath, server, err)
continue
}
if hasError {
os.Exit(3)
j, _ := json.Marshal(bd)
stdout(string(j))
}
}
}
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -138,12 +151,24 @@ var blossomCmd = &cli.Command{
outputs := c.StringSlice("output")
hasError := false
for i, hash := range c.Args().Slice() {
var hash [32]byte
for i, hhash := range c.Args().Slice() {
if len(hhash) != 64 {
log("invalid blob hash '%s'\n", hhash)
hasError = true
continue
}
if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(hhash), 64)); err != nil {
log("invalid blob hash '%s': %s\n", hhash, err)
hasError = true
continue
}
if len(outputs)-1 >= i && outputs[i] != "--" {
// save to this file
err := client.DownloadToFile(ctx, hash, outputs[i])
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
log("download failed for '%s': %s\n", hhash, err)
hasError = true
}
} else {
@@ -230,12 +255,55 @@ if any of the files are not found the command will fail, otherwise it will succe
},
},
{
Name: "mirror",
Usage: "",
Description: ``,
Name: "mirror",
Usage: "mirrors a from a server to another",
Description: `examples:
mirroring a single blob:
nak blossom mirror https://nostr.download/5672be22e6da91c12b929a0f46b9e74de8b5366b9b19a645ff949c24052f9ad4 -s blossom.band
mirroring all blobs from a certain pubkey from one server to the other:
nak blossom list 78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d -s nostr.download | nak blossom mirror -s blossom.band`,
DisableSliceFlagSeparator: true,
ArgsUsage: "",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
var bd blossom.BlobDescriptor
if input := c.Args().First(); input != "" {
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
return err
}
out, _ := json.Marshal(bd)
stdout(out)
return nil
}
for input := range getJsonsOrBlank() {
if input == "{}" {
continue
}
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
ctx = lineProcessingError(ctx, "failed to mirror '%s': %s", blobURL, err)
continue
}
out, _ := json.Marshal(bd)
stdout(out)
}
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -247,5 +315,9 @@ func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, err
if err != nil {
return nil, err
}
return blossom.NewClient(c.String("server"), keyer), nil
servers := c.StringSlice("server")
if err != nil {
return nil, err
}
return blossom.NewClient(servers[0], keyer), nil
}

656
bunker.go
View File

@@ -1,22 +1,30 @@
package main
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"maps"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip46"
"github.com/fatih/color"
"github.com/mdp/qrterminal/v3"
"github.com/urfave/cli/v3"
)
const PERSISTENCE = "PERSISTENCE"
var bunker = &cli.Command{
Name: "bunker",
Usage: "starts a nip46 signer daemon with the given --sec key",
@@ -24,6 +32,18 @@ var bunker = &cli.Command{
Description: ``,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persist",
Usage: "whether to read and store authorized keys from and to a config file",
Category: PERSISTENCE,
},
&cli.StringFlag{
Name: "profile",
Value: "default",
Usage: "config file name to use for --persist mode (implies that if provided) -- based on --config-path, i.e. ~/.config/nak/",
OnlyOnce: true,
Category: PERSISTENCE,
},
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as hex or nsec",
@@ -43,34 +63,181 @@ var bunker = &cli.Command{
Aliases: []string{"k"},
Usage: "pubkeys for which we will always respond",
},
&cli.StringSliceFlag{
Name: "relay",
Usage: "relays to connect to (can also be provided as naked arguments)",
Hidden: true,
},
&cli.BoolFlag{
Name: "qrcode",
Usage: "display a QR code for the bunker URI",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
// try to connect to the relays here
qs := url.Values{}
relayURLs := make([]string, 0, c.Args().Len())
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
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)
// read config from file
config := BunkerConfig{}
baseRelaysUrls := nostr.AppendUnique(c.Args().Slice(), c.StringSlice("relay")...)
for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url)
}
baseAuthorizedKeys := getPubKeySlice(c, "authorized-keys")
var baseSecret plainOrEncryptedKey
{
sec := c.String("sec")
if c.Bool("prompt-sec") {
var err error
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
if err != nil {
return fmt.Errorf("failed to get secret key: %w", err)
}
}
for _, relay := range relays {
relayURLs = append(relayURLs, relay.URL)
qs.Add("relay", relay.URL)
if strings.HasPrefix(sec, "ncryptsec1") {
baseSecret.Encrypted = &sec
} else if sec != "" {
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
sk := ski.(nostr.SecretKey)
baseSecret.Plain = &sk
} else if sk, err := nostr.SecretKeyFromHex(sec); err != nil {
return fmt.Errorf("invalid secret key: %w", err)
} else {
baseSecret.Plain = &sk
}
}
}
if len(relayURLs) == 0 {
// default case: persist() is nil
var persist func()
if c.Bool("persist") || c.IsSet("profile") {
path := filepath.Join(c.String("config-path"), "bunker")
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
path = filepath.Join(path, c.String("profile"))
persist = func() {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
log(color.RedString("failed to persist: %s\n"), err)
os.Exit(4)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
log(color.RedString("failed to persist: %s\n"), err)
os.Exit(4)
}
if err := os.WriteFile(path, data, 0600); err != nil {
log(color.RedString("failed to persist: %s\n"), err)
os.Exit(4)
}
}
log(color.YellowString("reading config from %s\n"), path)
b, err := os.ReadFile(path)
if err == nil {
if err := json.Unmarshal(b, &config); err != nil {
return err
}
// convert from deprecated field
if len(config.AuthorizedKeys) > 0 {
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
for i := range config.AuthorizedKeys {
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
}
config.AuthorizedKeys = nil
persist()
}
} else if !os.IsNotExist(err) {
return err
}
for i, url := range config.Relays {
config.Relays[i] = nostr.NormalizeURL(url)
}
config.Relays = nostr.AppendUnique(config.Relays, baseRelaysUrls...)
for _, bak := range baseAuthorizedKeys {
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
}
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
// we don't have any secret key stored, so just use whatever was given via flags
config.Secret = baseSecret
} else if baseSecret.Plain == nil && baseSecret.Encrypted == nil {
// we didn't provide any keys, so we just use the stored
} else {
// we have a secret key stored
// if we also provided a key we check if they match and fail otherwise
if !baseSecret.equals(config.Secret) {
return fmt.Errorf("--sec provided conflicts with stored, you should create a new --profile or omit the --sec flag")
}
}
} else {
config.Secret = baseSecret
config.Relays = baseRelaysUrls
for _, bak := range baseAuthorizedKeys {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
}
// if we got here without any keys set (no flags, first time using a profile), use the default
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
sec := os.Getenv("NOSTR_SECRET_KEY")
if sec == "" {
sec = defaultKey
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
return fmt.Errorf("default key is wrong: %w", err)
}
config.Secret.Plain = &sk
}
if len(config.Relays) == 0 {
return fmt.Errorf("no relays given")
}
// decrypt key here if necessary
var sec nostr.SecretKey
if config.Secret.Plain != nil {
sec = *config.Secret.Plain
} else {
plain, err := promptDecrypt(*config.Secret.Encrypted)
if err != nil {
return fmt.Errorf("failed to decrypt: %w", err)
}
sec = plain
}
if persist != nil {
persist()
}
// try to connect to the relays here
qs := url.Values{}
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
copy(allRelays, config.Relays)
for _, c := range config.Clients {
for _, url := range c.CustomRelays {
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
allRelays = append(allRelays, url)
}
}
}
relays := connectToAllRelays(ctx, c, allRelays, nil)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
for _, relay := range config.Relays {
qs.Add("relay", relay)
}
if len(relays) == 0 {
return fmt.Errorf("not connected to any relays: please specify at least one")
}
// gather the secret key
sec, _, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
// other arguments
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
@@ -83,14 +250,28 @@ var bunker = &cli.Command{
// this function will be called every now and then
printBunkerInfo := func() {
qs.Set("secret", newSecret)
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
iqs := make(url.Values)
maps.Copy(iqs, qs)
iqs.Set("secret", newSecret)
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), iqs.Encode())
authorizedKeysStr := ""
if len(authorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range authorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
if len(config.Clients) != 0 {
authorizedKeysStr = "\n authorized clients:"
for _, c := range config.Clients {
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
name := ""
if c.Name != "" {
name = c.Name
if c.URL != "" {
name += " " + colors.underline(c.URL)
}
} else if c.URL != "" {
name = colors.underline(c.URL)
}
if name != "" {
authorizedKeysStr += " (" + name + ")"
}
}
}
@@ -100,8 +281,8 @@ var bunker = &cli.Command{
}
preauthorizedFlags := ""
for _, k := range authorizedKeys {
preauthorizedFlags += " -k " + k.Hex()
for _, c := range config.Clients {
preauthorizedFlags += " -k " + c.PubKey.Hex()
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
@@ -112,8 +293,8 @@ var bunker = &cli.Command{
secretKeyFlag = "--sec " + sec
}
relayURLsPossiblyWithoutSchema := make([]string, len(relayURLs))
for i, url := range relayURLs {
relayURLsPossiblyWithoutSchema := make([]string, len(config.Relays))
for i, url := range config.Relays {
if strings.HasPrefix(url, "wss://") {
relayURLsPossiblyWithoutSchema[i] = url[6:]
} else {
@@ -121,48 +302,136 @@ var bunker = &cli.Command{
}
}
restartCommand := fmt.Sprintf("nak bunker %s%s %s",
secretKeyFlag,
preauthorizedFlags,
strings.Join(relayURLsPossiblyWithoutSchema, " "),
)
// only print the restart command if not persisting:
if persist == nil {
restartCommand := fmt.Sprintf("nak bunker %s%s %s",
secretKeyFlag,
preauthorizedFlags,
strings.Join(relayURLsPossiblyWithoutSchema, " "),
)
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.Hex()),
colors.bold(npub),
authorizedKeysStr,
authorizedSecretsStr,
color.CyanString(restartCommand),
colors.bold(bunkerURI),
)
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
colors.bold(config.Relays),
colors.bold(pubkey.Hex()),
colors.bold(npub),
authorizedKeysStr,
authorizedSecretsStr,
color.CyanString(restartCommand),
colors.bold(bunkerURI),
)
} else {
// otherwise just print the data
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n\n",
colors.bold(config.Relays),
colors.bold(pubkey.Hex()),
colors.bold(npub),
authorizedKeysStr,
authorizedSecretsStr,
colors.bold(bunkerURI),
)
}
// print QR code if requested
if c.Bool("qrcode") {
log("QR Code for bunker URI:\n")
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
log("\n\n")
}
}
printBunkerInfo()
// subscribe to relays
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
events := sys.Pool.SubscribeMany(ctx, allRelays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
signer := nip46.NewStaticKeySigner(sec)
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{}
signer.DefaultRelays = config.Relays
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
if persist != nil {
persist()
}
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
events <- event
}
}()
time.Sleep(time.Millisecond * 25)
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent through %s\n", res.Relay.URL)
} else {
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
}
}
}
}()
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
if slices.Contains(authorizedSecrets, secret) {
// add client to authorized list for subsequent requests
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
if persist != nil {
persist()
}
}
return true
}
if secret == newSecret {
// store this key
authorizedKeys = append(authorizedKeys, from)
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
// discard this and generate a new secret
newSecret = randString(12)
// print bunker info again after this
@@ -170,57 +439,70 @@ var bunker = &cli.Command{
time.Sleep(3 * time.Second)
printBunkerInfo()
}()
if persist != nil {
persist()
}
return true
}
return slices.Contains(authorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
return false
}
for ie := range events {
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// handle the NIP-46 request event
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
continue
}
jreq, _ := json.MarshalIndent(req, "", " ")
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))
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
log("* sent response through %s\n", relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
handlerWg.Done()
}
}(relayURL)
}
handlerWg.Wait()
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cancelPreviousBunkerInfoPrint = cancel
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
if errors.Is(err, nip46.AlreadyHandled) {
return
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
return
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := config.Relays
for _, c := range config.Clients {
if c.PubKey == from && len(c.CustomRelays) > 0 {
relays = c.CustomRelays
break
}
}
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent response through %s\n", res.Relay.URL)
} else {
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
}
}
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cancelPreviousBunkerInfoPrint = cancel
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
}
}()
}()
}
@@ -231,20 +513,194 @@ var bunker = &cli.Command{
Name: "connect",
Usage: "use the client-initiated NostrConnect flow of NIP46",
ArgsUsage: "<nostrconnect-uri>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "profile",
Usage: "profile name of the bunker to connect to",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("must be called with a nostrconnect://... uri")
}
uri, err := url.Parse(c.Args().First())
if err != nil || uri.Scheme != "nostrconnect" {
return fmt.Errorf("invalid uri")
if err := sendToSocket(c, c.Args().First()); err != nil {
return fmt.Errorf("failed to connect to running bunker: %w", err)
}
// TODO
return fmt.Errorf("this is not implemented yet")
return nil
},
},
},
}
type BunkerConfig struct {
Clients []BunkerConfigClient `json:"clients"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
// deprecated
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
}
type BunkerConfigClient struct {
PubKey nostr.PubKey `json:"pubkey"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
CustomRelays []string `json:"custom_relays,omitempty"`
}
type plainOrEncryptedKey struct {
Plain *nostr.SecretKey
Encrypted *string
}
func (pe plainOrEncryptedKey) MarshalJSON() ([]byte, error) {
if pe.Plain != nil {
res := make([]byte, 66)
hex.Encode(res[1:], (*pe.Plain)[:])
res[0] = '"'
res[65] = '"'
return res, nil
} else if pe.Encrypted != nil {
return json.Marshal(*pe.Encrypted)
}
return nil, fmt.Errorf("no key to marshal")
}
func (pe *plainOrEncryptedKey) UnmarshalJSON(buf []byte) error {
if len(buf) == 66 {
sk, err := nostr.SecretKeyFromHex(string(buf[1 : 1+64]))
if err != nil {
return err
}
pe.Plain = &sk
return nil
} else if bytes.HasPrefix(buf, []byte("\"nsec")) {
_, v, err := nip19.Decode(string(buf[1 : len(buf)-1]))
if err != nil {
return err
}
sk := v.(nostr.SecretKey)
pe.Plain = &sk
return nil
} else if bytes.HasPrefix(buf, []byte("\"ncryptsec1")) {
ncryptsec := string(buf[1 : len(buf)-1])
pe.Encrypted = &ncryptsec
return nil
}
return fmt.Errorf("unrecognized key format '%s'", string(buf))
}
func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
if a.Plain == nil && b.Plain != nil {
return false
}
if a.Plain != nil && b.Plain == nil {
return false
}
if a.Plain != nil && b.Plain != nil && *a.Plain != *b.Plain {
return false
}
if a.Encrypted == nil && b.Encrypted != nil {
return false
}
if a.Encrypted != nil && b.Encrypted == nil {
return false
}
if a.Encrypted != nil && b.Encrypted != nil && *a.Encrypted != *b.Encrypted {
return false
}
return true
}
func getSocketPath(c *cli.Command) string {
profile := "default"
if c.IsSet("profile") {
profile = c.String("profile")
}
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
}
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
res := make(chan *url.URL)
socketPath := getSocketPath(c)
// ensure directory exists
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
log(color.RedString("failed to create socket directory: %s\n", err))
return res
}
// delete existing socket file if it exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
log(color.RedString("failed to remove existing socket file: %s\n", err))
return res
}
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log(color.RedString("failed to listen on unix socket %s: %s\n", socketPath, err))
return res
}
go func() {
defer listener.Close()
defer os.Remove(socketPath) // cleanup socket file on exit
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
continue
}
}
go func(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
for {
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
break
}
uri, err := url.Parse(string(buf[:n]))
if err == nil && uri.Scheme == "nostrconnect" {
res <- uri
}
}
}(conn)
}
}()
return res
}
func sendToSocket(c *cli.Command, value string) error {
socketPath := getSocketPath(c)
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
}
defer conn.Close()
_, err = conn.Write([]byte(value))
if err != nil {
return fmt.Errorf("failed to send uri to bunker: %w", err)
}
return nil
}

View File

@@ -17,10 +17,9 @@ import (
func call(t *testing.T, cmd string) string {
var output strings.Builder
stdout = func(a ...any) (int, error) {
stdout = func(a ...any) {
output.WriteString(fmt.Sprint(a...))
output.WriteString("\n")
return 0, nil
}
err := app.Run(t.Context(), strings.Split(cmd, " "))
require.NoError(t, err)
@@ -137,13 +136,13 @@ func TestMultipleFetch(t *testing.T) {
require.Len(t, events, 2)
// First event validation
// 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
// 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())

218
count.go
View File

@@ -7,70 +7,61 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
var count = &cli.Command{
Name: "count",
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
},
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"COUNT\"",
Category: CATEGORY_SIGNER,
},
)...,
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
biggerUrlSize := 0
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
relays := connectToAllRelays(ctx, c, relayUrls, nil, nostr.PoolOptions{})
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -84,93 +75,62 @@ var count = &cli.Command{
}
}
filter := nostr.Filter{}
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = authors
}
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
kinds := make([]nostr.Kind, len(kinds64))
for i, v := range kinds64 {
kinds[i] = nostr.Kind(v)
}
filter.Kinds = kinds
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
}
}
if c.IsSet("since") {
filter.Since = getNaturalDate(c, "since")
}
if c.IsSet("until") {
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = int(limit)
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
// go line by line from stdin or run once with input from flags
for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{}
if stdinFilter != "" {
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
continue
}
}
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " 📋"
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
continue
}
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " (hll)"
}
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
}
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
exitIfLineProcessingError(ctx)
return nil
},
}

View File

@@ -120,8 +120,10 @@ func realCurl() error {
keyFlags = append(keyFlags, arg)
if arg != "--prompt-sec" {
i++
val := os.Args[i+2]
keyFlags = append(keyFlags, val)
if i+2 >= len(os.Args) {
break
}
keyFlags = append(keyFlags, os.Args[i+2])
}
} else {
curlFlags = append(curlFlags, arg)

View File

@@ -86,7 +86,7 @@ var decode = &cli.Command{
continue
}
ctx = lineProcessingError(ctx, "couldn't decode input '%s'", input)
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
}
exitIfLineProcessingError(ctx)

435
dekey.go Normal file
View File

@@ -0,0 +1,435 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip44"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var dekey = &cli.Command{
Name: "dekey",
Usage: "handles NIP-4E decoupled encryption keys",
Description: "maybe this picture will explain better than I can do here for now: https://cdn.azzamo.net/89c543d261ad0d665c1dea78f91e527c2e39e7fe503b440265a3c47e63c9139f.png",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "device",
Usage: "name of this device that will be published and displayed on other clients",
Value: func() string {
if hostname, err := os.Hostname(); err == nil {
return "nak@" + hostname
}
return "nak@unknown"
}(),
},
&cli.BoolFlag{
Name: "rotate",
Usage: "force the creation of a new decoupled encryption key, effectively invalidating any previous ones",
},
&cli.BoolFlag{
Name: "authorize-all",
Aliases: []string{"yolo"},
Usage: "do not ask for confirmation, just automatically send the decoupled encryption key to all devices that exist",
},
&cli.BoolFlag{
Name: "reject-all",
Usage: "do not ask for confirmation, just not send the decoupled encryption key to any device",
},
),
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
userPub, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get user public key: %w", err)
}
configPath := c.String("config-path")
deviceName := c.String("device")
log("handling device key for %s as %s\n",
color.YellowString(deviceName),
color.CyanString(nip19.EncodeNpub(userPub)),
)
// check if we already have a local-device secret key
deviceKeyPath := filepath.Join(configPath, "dekey", "device-key")
var deviceSec nostr.SecretKey
if data, err := os.ReadFile(deviceKeyPath); err == nil {
log(color.GreenString("found existing device key\n"))
deviceSec, err = nostr.SecretKeyFromHex(string(data))
if err != nil {
return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err)
}
} else {
log(color.YellowString("generating new device key\n"))
// create one
deviceSec = nostr.Generate()
os.MkdirAll(filepath.Dir(deviceKeyPath), 0700)
if err := os.WriteFile(deviceKeyPath, []byte(deviceSec.Hex()), 0600); err != nil {
return fmt.Errorf("failed to write device key: %w", err)
}
log(color.GreenString("device key generated and stored\n"))
}
devicePub := deviceSec.Public()
// get relays for the user
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
relays := sys.FetchWriteRelays(ctx, userPub)
relayList := connectToAllRelays(ctx, c, relays, nil)
if len(relayList) == 0 {
return fmt.Errorf("no relays to use")
}
// check for kind:10044
log("- checking for decoupled encryption key (kind:10044)\n")
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{userPub},
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
var eSec nostr.SecretKey
var ePub nostr.PubKey
var generateNewEncryptionKey bool
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""})
if !ok {
log("- no decoupled encryption key found, generating new one\n")
generateNewEncryptionKey = true
} else {
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
}
log(". a decoupled encryption public key already exists: %s\n", color.CyanString(ePub.Hex()))
if c.Bool("rotate") {
log(color.GreenString("rotating it by generating a new one\n"))
generateNewEncryptionKey = true
}
}
if generateNewEncryptionKey {
// generate main secret key
eSec = nostr.Generate()
ePub = eSec.Public()
// store it
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
if err := os.WriteFile(eKeyPath, []byte(eSec.Hex()), 0600); err != nil {
return fmt.Errorf("failed to write decoupled encryption key: %w", err)
}
log("decoupled encryption key generated and stored, public key: %s\n", color.CyanString(ePub.Hex()))
// publish kind:10044
log("publishing decoupled encryption public key (kind:10044)\n")
evt10044 := nostr.Event{
Kind: 10044,
Content: "",
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"n", ePub.Hex()},
},
}
if err := kr.SignEvent(ctx, &evt10044); err != nil {
return fmt.Errorf("failed to sign kind:10044: %w", err)
}
if err := publishFlow(ctx, c, kr, evt10044, relayList); err != nil {
return err
}
} else {
// check if we have the key
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
if data, err := os.ReadFile(eKeyPath); err == nil {
log(color.GreenString("- and we have it locally already\n"))
eSec, err = nostr.SecretKeyFromHex(string(data))
if err != nil {
return fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
}
} else {
log("- decoupled encryption key not found locally, attempting to fetch the key from other devices\n")
// check if our kind:4454 is already published
log("- checking for existing device announcement (kind:4454)\n")
ourDeviceAnnouncementEvents := make([]nostr.Event, 0, 1)
for evt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4454},
Authors: []nostr.PubKey{userPub},
Tags: nostr.TagMap{
"P": []string{devicePub.Hex()},
},
Limit: 1,
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt.Event)
}
if len(ourDeviceAnnouncementEvents) == 0 {
log(". no device announcement found, publishing kind:4454 for %s\n", color.YellowString(deviceName))
// publish kind:4454
evt := nostr.Event{
Kind: 4454,
Content: "",
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"client", deviceName},
{"P", devicePub.Hex()},
},
}
// sign with main key
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign device event: %w", err)
}
// publish
if err := publishFlow(ctx, c, kr, evt, relayList); err != nil {
return err
}
log(color.GreenString(". device announcement published\n"))
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt)
} else {
log(color.GreenString(". device already registered\n"))
}
// see if some other device has shared the key with us from kind:4455
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4455},
Tags: nostr.TagMap{
"p": []string{devicePub.Hex()},
},
Since: keyAnnouncementEvent.CreatedAt + 1,
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
var senderPub nostr.PubKey
for _, tag := range eKeyMsg.Tags {
if len(tag) >= 2 && tag[0] == "P" {
senderPub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if senderPub == nostr.ZeroPK {
continue
}
ss, err := nip44.GenerateConversationKey(senderPub, deviceSec)
if err != nil {
continue
}
eSecHex, err := nip44.Decrypt(eKeyMsg.Content, ss)
if err != nil {
continue
}
eSec, err = nostr.SecretKeyFromHex(eSecHex)
if err != nil {
continue
}
// check if it matches mainPub
if eSec.Public() == ePub {
log(color.GreenString("successfully received decoupled encryption key from another device\n"))
// store it
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
// delete our 4454 if we had one, since we received the key
if len(ourDeviceAnnouncementEvents) > 0 {
log("deleting our device announcement (kind:4454) since we received the decoupled encryption key\n")
deletion4454 := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 5,
Tags: nostr.Tags{
{"e", ourDeviceAnnouncementEvents[0].ID.Hex()},
},
}
if err := kr.SignEvent(ctx, &deletion4454); err != nil {
log(color.RedString("failed to sign 4454 deletion: %v\n"), err)
} else if err := publishFlow(ctx, c, kr, deletion4454, relayList); err != nil {
log(color.RedString("failed to publish 4454 deletion: %v\n"), err)
} else {
log(color.GreenString("- device announcement deleted\n"))
}
}
// delete the 4455 we just decrypted
log("deleting the key message (kind:4455) we just decrypted\n")
deletion4455 := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 5,
Tags: nostr.Tags{
{"e", eKeyMsg.ID.Hex()},
},
}
if err := kr.SignEvent(ctx, &deletion4455); err != nil {
log(color.RedString("failed to sign 4455 deletion: %v\n"), err)
} else if err := publishFlow(ctx, c, kr, deletion4455, relayList); err != nil {
log(color.RedString("failed to publish 4455 deletion: %v\n"), err)
} else {
log(color.GreenString("- key message deleted\n"))
}
break
}
}
}
}
if eSec == [32]byte{} {
log("decoupled encryption secret key not available, must be sent from another device to %s first\n",
color.YellowString(deviceName))
return nil
}
log(color.GreenString("- decoupled encryption key ready\n"))
// now we have mainSec, check for other kind:4454 events newer than the 10044
log("- checking for other devices and key messages so we can send the key\n")
keyMsgs := make([]string, 0, 5)
for keyOrDeviceEvt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4454, 4455},
Authors: []nostr.PubKey{userPub},
Since: keyAnnouncementEvent.CreatedAt + 1,
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
if keyOrDeviceEvt.Kind == 4455 {
// got key event
keyEvent := keyOrDeviceEvt
// assume a key msg will always come before its associated devicemsg
// so just store them here:
pubkeyTag := keyEvent.Tags.Find("p")
if pubkeyTag == nil {
continue
}
keyMsgs = append(keyMsgs, pubkeyTag[1])
} else if keyOrDeviceEvt.Kind == 4454 {
// device event
deviceEvt := keyOrDeviceEvt
// skip ourselves
if deviceEvt.Tags.FindWithValue("P", devicePub.Hex()) != nil {
continue
}
// if there is a clock skew (current time is earlier than the time of this device's announcement) skip it
if nostr.Now() < deviceEvt.CreatedAt {
continue
}
// if this already has a corresponding keyMsg then skip it
pubkeyTag := deviceEvt.Tags.Find("P")
if pubkeyTag == nil {
continue
}
if slices.Contains(keyMsgs, pubkeyTag[1]) {
continue
}
deviceTag := deviceEvt.Tags.Find("client")
if deviceTag == nil {
continue
}
// here we know we're dealing with a deviceMsg without a corresponding keyMsg
// so we have to build a keyMsg for them
theirDevice, err := nostr.PubKeyFromHex(pubkeyTag[1])
if err != nil {
continue
}
if c.Bool("authorize-all") {
// will proceed
} else if c.Bool("reject-all") {
log(" - skipping %s\n", color.YellowString(deviceTag[1]))
continue
} else {
var proceed bool
if err := survey.AskOne(&survey.Confirm{
Message: fmt.Sprintf("share decoupled encryption key with %s"+colors.bold("?"),
color.YellowString(deviceTag[1])),
}, &proceed); err != nil {
return err
}
if proceed {
// will proceed
} else {
// won't proceed
var deleteDevice bool
if err := survey.AskOne(&survey.Confirm{
Message: fmt.Sprintf(" delete %s"+colors.bold("'s announcement?"), color.YellowString(deviceTag[1])),
}, &deleteDevice); err != nil {
return err
}
if deleteDevice {
log(" - deleting %s\n", color.YellowString(deviceTag[1]))
deletion := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 5,
Tags: nostr.Tags{
{"e", deviceEvt.ID.Hex()},
},
}
if err := kr.SignEvent(ctx, &deletion); err != nil {
return fmt.Errorf("failed to sign deletion '%s': %w", deletion.GetID().Hex(), err)
}
if err := publishFlow(ctx, c, kr, deletion, relayList); err != nil {
return fmt.Errorf("publish flow failed: %w", err)
}
} else {
log(" - skipped\n")
}
continue
}
}
log("- sending decoupled encryption key to new device %s\n", color.YellowString(deviceTag[1]))
ss, err := nip44.GenerateConversationKey(theirDevice, deviceSec)
if err != nil {
continue
}
ciphertext, err := nip44.Encrypt(eSec.Hex(), ss)
if err != nil {
continue
}
evt4455 := nostr.Event{
Kind: 4455,
Content: ciphertext,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"p", theirDevice.Hex()},
{"P", devicePub.Hex()},
},
}
if err := kr.SignEvent(ctx, &evt4455); err != nil {
continue
}
if err := publishFlow(ctx, c, kr, evt4455, relayList); err != nil {
log(color.RedString("failed to publish key message: %v\n"), err)
} else {
log(" - decoupled encryption key sent to %s\n", color.GreenString(deviceTag[1]))
}
}
}
stdout(ePub.Hex())
return nil
},
}

244
encode.go
View File

@@ -2,13 +2,66 @@ package main
import (
"context"
stdjson "encoding/json"
"fmt"
"iter"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/urfave/cli/v3"
)
func parseProfilePointerInput(target string) (nostr.ProfilePointer, bool) {
var profilePtr nostr.ProfilePointer
if err := stdjson.Unmarshal([]byte(target), &profilePtr); err != nil || profilePtr.PublicKey == nostr.ZeroPK {
return nostr.ProfilePointer{}, false
}
return profilePtr, true
}
func parseEventPointerInput(target string) (nostr.EventPointer, bool) {
var eventPtr nostr.EventPointer
if err := stdjson.Unmarshal([]byte(target), &eventPtr); err != nil || eventPtr.ID == nostr.ZeroID {
return nostr.EventPointer{}, false
}
return eventPtr, true
}
func parseEntityPointerInput(target string) (nostr.EntityPointer, bool) {
var entityPtr nostr.EntityPointer
if err := stdjson.Unmarshal([]byte(target), &entityPtr); err != nil || entityPtr.PublicKey == nostr.ZeroPK || entityPtr.Kind == 0 {
return nostr.EntityPointer{}, false
}
return entityPtr, true
}
func getEncodeSubcommandInput(args cli.Args, allowBlank bool) iter.Seq[string] {
if args.Len() > 0 {
return func(yield func(string) bool) {
for _, arg := range args.Slice() {
if !yield(arg) {
return
}
}
}
}
return func(yield func(string) bool) {
for jsonStr := range getJsonsOrBlank() {
if jsonStr == "{}" {
if allowBlank {
yield("")
}
return
}
if !yield(jsonStr) {
return
}
}
}
}
var encode = &cli.Command{
Name: "encode",
Usage: "encodes notes and other stuff to nip19 entities",
@@ -21,21 +74,19 @@ var encode = &cli.Command{
nak encode nsec <privkey-hex>
echo '{"pubkey":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19","relays":["wss://nada.zero"]}' | nak encode
echo '{
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19"
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19",
"relays":["wss://nada.zero"],
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
} | nak encode`,
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
switch c.Args().First() {
case "naddr", "nevent", "npub", "nprofile", "nsec":
return nil
}
return fmt.Errorf("unknown encode target '%s'", c.Args().First())
}
relays := c.StringSlice("relay")
@@ -52,21 +103,18 @@ var encode = &cli.Command{
hasStdin = true
}
var eventPtr nostr.EventPointer
if err := json.Unmarshal([]byte(jsonStr), &eventPtr); err == nil && eventPtr.ID != nostr.ZeroID {
stdout(nip19.EncodeNevent(eventPtr.ID, appendUnique(relays, eventPtr.Relays...), eventPtr.Author))
if eventPtr, ok := parseEventPointerInput(jsonStr); ok {
stdout(nip19.EncodeNevent(eventPtr.ID, nostr.AppendUnique(relays, eventPtr.Relays...), eventPtr.Author))
continue
}
var profilePtr nostr.ProfilePointer
if err := json.Unmarshal([]byte(jsonStr), &profilePtr); err == nil && profilePtr.PublicKey != nostr.ZeroPK {
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, appendUnique(relays, profilePtr.Relays...)))
if entityPtr, ok := parseEntityPointerInput(jsonStr); ok {
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, nostr.AppendUnique(relays, entityPtr.Relays...)))
continue
}
var entityPtr nostr.EntityPointer
if err := json.Unmarshal([]byte(jsonStr), &entityPtr); err == nil && entityPtr.PublicKey != nostr.ZeroPK {
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, appendUnique(relays, entityPtr.Relays...)))
if profilePtr, ok := parseProfilePointerInput(jsonStr); ok {
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, nostr.AppendUnique(relays, profilePtr.Relays...)))
continue
}
@@ -89,7 +137,7 @@ var encode = &cli.Command{
for target := range getStdinLinesOrArguments(c.Args()) {
pk, err := nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
continue
}
@@ -108,7 +156,7 @@ var encode = &cli.Command{
for target := range getStdinLinesOrArguments(c.Args()) {
sk, err := nostr.SecretKeyFromHex(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid private key '%s': %w", target, err)
ctx = lineProcessingError(ctx, "invalid private key '%s': %s", target, err)
continue
}
@@ -126,19 +174,38 @@ var encode = &cli.Command{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to nprofile code",
Usage: "attach relay hints to the code",
},
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
pk, err := nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
continue
for target := range getEncodeSubcommandInput(c.Args(), false) {
relays := c.StringSlice("relay")
pk := nostr.ZeroPK
if profilePtr, ok := parseProfilePointerInput(target); ok {
pk = profilePtr.PublicKey
relays = nostr.AppendUnique(relays, profilePtr.Relays...)
} else {
var err error
pk, err = nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
continue
}
}
if getBoolInt(c, "outbox") > 0 {
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
relays = nostr.AppendUnique(relays, r)
}
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
@@ -159,18 +226,45 @@ var encode = &cli.Command{
Aliases: []string{"a"},
Usage: "attach an author pubkey as a hint to the nevent code",
},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to the code",
},
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
id, err := nostr.IDFromHex(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue
}
for target := range getEncodeSubcommandInput(c.Args(), false) {
id := nostr.ZeroID
author := getPubKey(c, "author")
relays := c.StringSlice("relay")
if eventPtr, ok := parseEventPointerInput(target); ok {
id = eventPtr.ID
relays = nostr.AppendUnique(relays, eventPtr.Relays...)
if author == nostr.ZeroPK {
author = eventPtr.Author
}
} else {
var err error
id, err = parseEventID(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue
}
}
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
relays = nostr.AppendUnique(relays, r)
}
}
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
@@ -187,43 +281,85 @@ var encode = &cli.Command{
Usage: "generate codes for addressable events",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "identifier",
Aliases: []string{"d"},
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
Required: true,
Name: "identifier",
Aliases: []string{"d"},
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
},
&PubKeyFlag{
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
Required: true,
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
},
&cli.IntFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
Required: true,
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to the code",
},
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for d := range getStdinLinesOrBlank() {
for target := range getEncodeSubcommandInput(c.Args(), true) {
pubkey := getPubKey(c, "pubkey")
kind := nostr.Kind(c.Int("kind"))
d := c.String("identifier")
relays := c.StringSlice("relay")
kind := c.Int("kind")
if kind < 30000 || kind >= 40000 {
return fmt.Errorf("kind must be between 30000 and 39999, got %d", kind)
if entityPtr, ok := parseEntityPointerInput(target); ok {
relays = nostr.AppendUnique(relays, entityPtr.Relays...)
if pubkey == nostr.ZeroPK {
pubkey = entityPtr.PublicKey
}
if kind == 0 {
kind = entityPtr.Kind
}
if !c.IsSet("identifier") {
d = entityPtr.Identifier
}
} else if target != "" {
d = target
}
if d == "" {
d = c.String("identifier")
if pubkey == nostr.ZeroPK {
ctx = lineProcessingError(ctx, "pubkey must be set")
continue
}
if kind == 0 {
ctx = lineProcessingError(ctx, "kind must be set")
continue
}
if kind.IsAddressable() {
if d == "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
ctx = lineProcessingError(ctx, "\"d\" tag identifier must be set for addressable events")
continue
}
} else if kind.IsReplaceable() {
if d != "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier must not be set for replaceable events")
continue
}
} else {
ctx = lineProcessingError(ctx, "can only encode addressable events")
continue
}
if getBoolInt(c, "outbox") > 0 {
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
relays = nostr.AppendUnique(relays, r)
}
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}

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)
}
@@ -123,7 +123,7 @@ var decrypt = &cli.Command{
res, err := kr.Decrypt(ctx, ciphertext, source)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
return fmt.Errorf("failed to decrypt: %w", err)
}
stdout(res)
}

165
event.go
View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"slices"
@@ -9,6 +10,7 @@ import (
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip13"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
@@ -22,7 +24,7 @@ const (
CATEGORY_EXTRAS = "EXTRAS"
)
var event = &cli.Command{
var eventCmd = &cli.Command{
Name: "event",
Usage: "generates an encoded event and either prints it or sends it to a set of relays",
Description: `outputs an event built with the flags. if one or more relays are given as arguments, an attempt is also made to publish the event to these relays.
@@ -64,6 +66,18 @@ example:
Hidden: true,
},
// ~~~
&cli.BoolFlag{
Name: "force-sign",
Usage: "when an event is already signed and not modified it isn't signed again even when a different --sec is given, this option negates that",
Value: false,
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "no-sign",
Usage: "print the event without signing it, using the specified pubkey",
Value: false,
Category: CATEGORY_SIGNER,
},
&cli.UintFlag{
Name: "pow",
Usage: "nip13 difficulty to target when doing hash work on the event id",
@@ -84,6 +98,12 @@ example:
Usage: "print the nevent code (to stderr) after the event is published",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "outbox",
Usage: "publish to the \"write\" relays of the author and to the \"read\" relays of anyone mentioned in \"p\" tags",
DefaultText: "false, will only use manually-specified relays",
Category: CATEGORY_EXTRAS,
},
&cli.UintFlag{
Name: "kind",
Aliases: []string{"k"},
@@ -92,6 +112,12 @@ example:
Value: 0,
Category: CATEGORY_EVENT_FIELDS,
},
&PubKeyOrAddressFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "set the event pubkey or add an 'a' tag if it's an address",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringFlag{
Name: "content",
Aliases: []string{"c"},
@@ -121,6 +147,11 @@ example:
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringSliceFlag{
Name: "h",
Usage: "shortcut for --tag h=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&NaturalTimeFlag{
Name: "created-at",
Aliases: []string{"time", "ts"},
@@ -137,22 +168,8 @@ example:
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
// try to connect to the relays here
var relays []*nostr.Relay
relayUrls := c.Args().Slice()
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
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)
}
}
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
@@ -166,8 +183,14 @@ example:
// this is called when we have a valid json from stdin
handleEvent := func(stdinEvent string) error {
evt.Content = ""
evt.CreatedAt = 0
clear(evt.Tags)
evt.ID = nostr.ZeroID
evt.PubKey = nostr.ZeroPK
evt.Sig = [64]byte{}
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
contentWasSupplied := strings.Contains(stdinEvent, `"content"`)
mustRehashAndResign := false
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
@@ -194,7 +217,7 @@ example:
evt.Content = content
}
mustRehashAndResign = true
} else if evt.Content == "" && evt.Kind == 1 {
} else if !contentWasSupplied && evt.Content == "" && evt.Kind == 1 {
evt.Content = "hello from the nostr army knife"
mustRehashAndResign = true
}
@@ -208,19 +231,28 @@ example:
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
val := tagValues[0]
if len(tagName) == 1 {
val = decodeTagValue(val, rune(tagName[0]))
}
if len(tagValues) >= 1 {
tagValues[0] = val
}
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, 'e')
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, 'p')
if tags.FindWithValue("p", decodedPtag) == nil {
tags = append(tags, nostr.Tag{"p", decodedPtag})
}
}
for _, dtag := range c.StringSlice("d") {
@@ -228,12 +260,40 @@ example:
tags = append(tags, nostr.Tag{"d", dtag})
}
}
for _, htag := range c.StringSlice("h") {
if tags.FindWithValue("h", htag) == nil {
tags = append(tags, nostr.Tag{"h", htag})
}
}
var authorPubKey nostr.PubKey
for _, a := range getPubKeyOrAddressSlice(c, "author") {
// is it an address?
if a.Addr != nil {
aTag := a.Addr.AsTagReference()
if tags.FindWithValue("a", aTag) == nil {
tags = append(tags, nostr.Tag{"a", aTag})
}
continue
}
// or is it an "author" pubkey?
if a.PubKey != nostr.ZeroPK {
if authorPubKey != nostr.ZeroPK {
return fmt.Errorf("multiple author pubkeys provided")
}
authorPubKey = a.PubKey
}
}
if len(tags) > 0 {
for _, tag := range tags {
evt.Tags = append(evt.Tags, tag)
}
mustRehashAndResign = true
}
if authorPubKey != nostr.ZeroPK {
evt.PubKey = authorPubKey
}
if c.IsSet("created-at") {
evt.CreatedAt = getNaturalDate(c, "created-at")
@@ -243,7 +303,7 @@ example:
mustRehashAndResign = true
}
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
if c.IsSet("musig") || c.Bool("force-sign") {
mustRehashAndResign = true
}
@@ -258,7 +318,7 @@ example:
if err != nil {
return err
}
} else {
} else if evt.PubKey == nostr.ZeroPK {
evt.PubKey, _ = kr.GetPublicKey(ctx)
}
@@ -269,7 +329,13 @@ example:
mustRehashAndResign = true
}
if evt.Sig == [64]byte{} || mustRehashAndResign {
if c.Bool("no-sign") {
if evt.PubKey == nostr.ZeroPK {
return fmt.Errorf("--no-sign requires a pubkey in the event or via --author")
}
evt.ID = nostr.ZeroID
evt.Sig = [64]byte{}
} else if evt.Sig == [64]byte{} || mustRehashAndResign {
if numSigners := c.Uint("musig"); numSigners > 1 {
// must do musig
pubkeys := c.StringSlice("musig-pubkey")
@@ -287,10 +353,49 @@ example:
return nil
}
} else if err := kr.SignEvent(ctx, &evt); err != nil {
if _, isBunker := kr.(keyer.BunkerSigner); isBunker && errors.Is(ctx.Err(), context.DeadlineExceeded) {
err = fmt.Errorf("timeout waiting for bunker to respond")
}
return fmt.Errorf("error signing with provided key: %w", err)
}
}
var relays []*nostr.Relay
if len(relayUrls) > 0 || c.Bool("outbox") {
if c.Bool("outbox") {
if evt.PubKey != nostr.ZeroPK {
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchWriteRelays(ctx, evt.PubKey)...)
}
seenPubkeys := make(map[nostr.PubKey]struct{}, len(evt.Tags))
for _, tag := range evt.Tags {
if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") {
continue
}
pk, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
if _, ok := seenPubkeys[pk]; ok {
continue
}
seenPubkeys[pk] = struct{}{}
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchInboxRelays(ctx, pk, 15)...)
}
}
if len(relayUrls) > 0 {
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays = connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
}
}
// print event as json
var result string
if c.Bool("envelope") {
@@ -327,7 +432,8 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
if c.Bool("confirm") {
relaysStr := make([]string, len(relays))
for i, r := range relays {
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
_, after, _ := strings.Cut(r.URL, "://")
relaysStr[i] = strings.ToLower(after)
}
time.Sleep(time.Millisecond * 10)
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
@@ -397,13 +503,20 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
}
} else {
// normal dumb flow
for _, relay := range relays {
for i, 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()
if !relay.IsConnected() {
if new_, err := sys.Pool.EnsureRelay(relay.URL); err == nil {
relays[i] = new_
relay = new_
}
}
err := relay.Publish(ctx, evt)
if err == nil {
// published fine

View File

@@ -3,11 +3,14 @@ package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk/hints"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -18,12 +21,18 @@ var fetch = &cli.Command{
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
DisableSliceFlagSeparator: true,
Flags: append(reqFilterFlags,
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
)...,
),
ArgsUsage: "[nip05_or_nip19_code]",
Action: func(ctx context.Context, c *cli.Command) error {
@@ -106,9 +115,30 @@ var fetch = &cli.Command{
continue
}
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{}) {
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
found := false
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-fetch",
}) {
found = true
stdout(ie.Event)
}
if !found {
ctx = lineProcessingError(ctx, "no events found for %s", code)
}
}
exitIfLineProcessingError(ctx)

118
filter.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"context"
"fmt"
"fiatjaf.com/nostr"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
var filterCmd = &cli.Command{
Name: "filter",
Usage: "applies an event filter to an event to see if it matches.",
Description: `
example:
echo '{"kind": 1, "content": "hello"}' | nak filter -k 1
nak filter '{"kind": 1, "content": "hello"}' -k 1
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
`,
DisableSliceFlagSeparator: true,
Flags: append(append([]cli.Flag{}, reqFilterFlags...),
&cli.StringFlag{
Name: "jq",
Usage: "filter matching events with jq expression",
},
),
ArgsUsage: "[event_json] [base_filter_json]",
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
var baseFilter nostr.Filter
var baseEvent nostr.Event
if len(args) == 2 {
// two arguments: first is event, second is base filter
if err := easyjson.Unmarshal([]byte(args[0]), &baseEvent); err != nil {
return fmt.Errorf("invalid base event: %w", err)
}
if err := easyjson.Unmarshal([]byte(args[1]), &baseFilter); err != nil {
return fmt.Errorf("invalid base filter: %w", err)
}
} else if len(args) == 1 {
if isPiped() {
// one argument + stdin: argument is base filter
if err := easyjson.Unmarshal([]byte(args[0]), &baseFilter); err != nil {
return fmt.Errorf("invalid base filter: %w", err)
}
} else {
// one argument, no stdin: argument is event
if err := easyjson.Unmarshal([]byte(args[0]), &baseEvent); err != nil {
return fmt.Errorf("invalid base event: %w", err)
}
}
}
// apply flags to filter
if err := applyFlagsToFilter(c, &baseFilter); err != nil {
return err
}
jq, err := jqPrepare(c.String("jq"))
if err != nil {
return err
}
// if there is no stdin we'll still get an empty object here
for evtj := range getJsonsOrBlank() {
var evt nostr.Event
if err := easyjson.Unmarshal([]byte(evtj), &evt); err != nil {
ctx = lineProcessingError(ctx, "invalid event: %s", err)
continue
}
// merge that with the base event
if evt.ID == nostr.ZeroID {
evt.ID = baseEvent.ID
}
if evt.PubKey == nostr.ZeroPK {
evt.PubKey = baseEvent.PubKey
}
if evt.Sig == [64]byte{} {
evt.Sig = baseEvent.Sig
}
if evt.Content == "" {
evt.Content = baseEvent.Content
}
if len(evt.Tags) == 0 {
evt.Tags = baseEvent.Tags
}
if evt.CreatedAt == 0 {
evt.CreatedAt = baseEvent.CreatedAt
}
if baseFilter.Matches(evt) {
var out string
if jq == nil {
out = evt.String()
} else {
v, matches, err := jq(evt)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if !matches {
continue
}
out, _ = json.MarshalToString(v)
}
stdout(out)
} else {
logverbose("event %s didn't match %s", evt, baseFilter)
}
}
exitIfLineProcessingError(ctx)
return nil
},
}

126
flags.go
View File

@@ -11,6 +11,62 @@ import (
"github.com/urfave/cli/v3"
)
type (
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
)
type boolIntValue struct {
int int
defaultWhenSet int
hasDefault bool
hasBeenSet bool
}
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
*p = val
return &boolIntValue{
defaultWhenSet: val,
hasDefault: true,
}
}
func (t boolIntValue) IsBoolFlag() bool {
return true
}
func (t boolIntValue) ToString(b int) string { return "<<>>" }
func (t *boolIntValue) Set(value string) error {
t.hasBeenSet = true
if value == "true" {
if t.hasDefault {
t.int = t.defaultWhenSet
} else {
t.int = 1
}
return nil
} else {
var err error
t.int, err = strconv.Atoi(value)
return err
}
}
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
func (t *boolIntValue) Value() int { return t.int }
func (t *boolIntValue) Get() any { return t.int }
func getBoolInt(cmd *cli.Command, name string) int {
return cmd.Value(name).(int)
}
//
//
//
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
type naturalTimeValue struct {
@@ -49,10 +105,10 @@ func (t *naturalTimeValue) Set(value string) error {
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, value)
ts = date.Time
if err != nil {
return err
}
ts = date.Time
}
if t.timestamp != nil {
@@ -96,8 +152,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
}
@@ -127,6 +183,66 @@ func getPubKeySlice(cmd *cli.Command, name string) []nostr.PubKey {
//
//
type PubKeyOrAddress struct {
PubKey nostr.PubKey
Addr *nostr.EntityPointer
}
type (
pubKeyOrAddressValue struct {
value PubKeyOrAddress
hasBeenSet bool
}
pubKeyOrAddressSlice = cli.SliceBase[PubKeyOrAddress, struct{}, pubKeyOrAddressValue]
PubKeyOrAddressFlag = cli.FlagBase[[]PubKeyOrAddress, struct{}, pubKeyOrAddressSlice]
)
var _ cli.ValueCreator[PubKeyOrAddress, struct{}] = pubKeyOrAddressValue{}
func (t pubKeyOrAddressValue) Create(val PubKeyOrAddress, p *PubKeyOrAddress, c struct{}) cli.Value {
*p = val
return &pubKeyOrAddressValue{
value: val,
}
}
func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string {
if b.Addr != nil {
return b.Addr.AsTagReference()
}
return b.PubKey.String()
}
func (t *pubKeyOrAddressValue) Set(value string) error {
pubkey, err1 := parsePubKey(value)
if err1 == nil {
t.value = PubKeyOrAddress{PubKey: pubkey}
t.hasBeenSet = true
return nil
}
addr, err2 := nostr.ParseAddrString(value)
if err2 == nil {
t.value = PubKeyOrAddress{Addr: &addr}
t.hasBeenSet = true
return nil
}
return fmt.Errorf("value is neither a pubkey or an address: %w; %w", err1, err2)
}
func (t *pubKeyOrAddressValue) String() string { return fmt.Sprintf("%#v", t.value) }
func (t *pubKeyOrAddressValue) Value() PubKeyOrAddress { return t.value }
func (t *pubKeyOrAddressValue) Get() any { return t.value }
func getPubKeyOrAddressSlice(cmd *cli.Command, name string) []PubKeyOrAddress {
return cmd.Value(name).([]PubKeyOrAddress)
}
//
//
//
type (
IDFlag = cli.FlagBase[nostr.ID, struct{}, idValue]
)
@@ -147,8 +263,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
}

2
fs.go
View File

@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !openbsd && !cgofuse
package main

118
fs_cgo.go Normal file
View File

@@ -0,0 +1,118 @@
//go:build cgofuse && !windows && !openbsd
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: append(defaultKeyFlags,
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
},
&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()
if mountpoint == "" {
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(
context.WithValue(
context.WithValue(
ctx,
"log", log,
),
"logverbose", logverbose,
),
sys,
kr,
mountpoint,
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
)
// create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint))
// create cgofuse host
host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true)
host.SetUseIno(true)
// mount the filesystem
mountArgs := []string{"-s", mountpoint}
if isVerbose {
mountArgs = append([]string{"-d"}, mountArgs...)
}
go func() {
host.Mount("", mountArgs)
}()
log("ok.\n")
// setup signal handling for clean unmount
ch := make(chan os.Signal, 1)
chErr := make(chan error)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-ch
log("- unmounting... ")
// cgofuse doesn't have explicit unmount, it unmounts on process exit
log("ok\n")
chErr <- nil
}()
// wait for signals
return <-chErr
},
}

20
fs_other.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build openbsd
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 and OpenBSD.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("this doesn't work on OpenBSD.")
},
}

View File

@@ -5,16 +5,136 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `doesn't work on Windows.`,
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: append(defaultKeyFlags,
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
},
&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 {
return fmt.Errorf("this doesn't work on Windows.")
mountpoint := c.Args().First()
if mountpoint == "" {
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(
context.WithValue(
context.WithValue(
ctx,
"log", log,
),
"logverbose", logverbose,
),
sys,
kr,
mountpoint,
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
)
// create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint))
// create cgofuse host
host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true)
host.SetUseIno(true)
// mount the filesystem - Windows/WinFsp version
// based on rclone cmount implementation
mountArgs := []string{
"-o", "uid=-1",
"-o", "gid=-1",
"--FileSystemName=nak",
}
// check if mountpoint is a drive letter or directory
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
if !isDriveLetter {
// winFsp primarily supports drive letters on Windows
// directory mounting may not work reliably
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
// for directory mounts, follow rclone's approach:
// 1. check that mountpoint doesn't already exist
if _, err := os.Stat(mountpoint); err == nil {
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to check mountpoint: %w", err)
}
// 2. check that parent directory exists
parent := filepath.Join(mountpoint, "..")
if _, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent)
}
return fmt.Errorf("failed to check parent directory: %w", err)
}
// 3. use network mode for directory mounts
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
}
if isVerbose {
mountArgs = append(mountArgs, "-o", "debug")
}
mountArgs = append(mountArgs, mountpoint)
log("ok.\n")
if !host.Mount("", mountArgs) {
return fmt.Errorf("failed to mount filesystem")
}
return nil
},
}

372
gift.go Normal file
View File

@@ -0,0 +1,372 @@
package main
import (
"context"
"fmt"
"math/rand"
"os"
"path/filepath"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip44"
"github.com/fatih/color"
"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>
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "wrap",
Flags: []cli.Flag{
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
Required: true,
},
&cli.BoolFlag{
Name: "use-our-identity-key",
Usage: "Encrypt with the key given to --sec directly even when a decoupled key exists for the sender.",
},
&cli.BoolFlag{
Name: "use-their-identity-key",
Usage: "Encrypt to the public key given as --recipient-pubkey directly even when a decoupled key exists for the receiver.",
},
},
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
}
// get sender pubkey (ourselves)
sender, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get sender pubkey: %w", err)
}
var using bool
var cipher nostr.Cipher = kr
// use decoupled key if it exists
using = false
if !c.Bool("use-our-identity-key") {
configPath := c.String("config-path")
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, sender)
if has {
if err != nil {
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --encrypt-with-our-identity-key to bypass", err)
}
cipher = keyer.NewPlainKeySigner(eSec)
log("- using our decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
using = true
}
}
if !using {
log("- using our identity key %s\n", color.CyanString(sender.Hex()))
}
recipient := getPubKey(c, "recipient-pubkey")
using = false
if !c.Bool("use-their-identity-key") {
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, recipient); exists {
recipient = theirEPub
using = true
log("- using their decoupled encryption public key %s\n", color.CyanString(theirEPub.Hex()))
}
}
if !using {
log("- using their identity public key %s\n", color.CyanString(recipient.Hex()))
}
// 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 := cipher.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>`,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
// get receiver public key (ourselves)
receiver, err := kr.GetPublicKey(ctx)
if err != nil {
return err
}
ciphers := []nostr.Cipher{kr}
// use decoupled key if it exists
configPath := c.String("config-path")
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
if has {
if err != nil {
return fmt.Errorf("receiver's decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
}
ciphers = append(ciphers, kr)
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
}
// 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)
}
// decrypt seal (in the process also find out if they encrypted it to our identity key or to our decoupled key)
var cipher nostr.Cipher
var seal nostr.Event
// try both the receiver identity key and decoupled key
err = nil
for c, potentialCipher := range ciphers {
switch c {
case 0:
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
case 1:
if eSec.Public() == nostr.ZeroPK {
continue
}
log("- trying receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
}
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
if thisErr != nil {
err = thisErr
continue
}
if thisErr := easyjson.Unmarshal([]byte(sealj), &seal); thisErr != nil {
err = fmt.Errorf("invalid seal JSON: %w", thisErr)
continue
}
cipher = potentialCipher
break
}
if seal.ID == nostr.ZeroID && seal.PubKey == nostr.ZeroPK && seal.CreatedAt == 0 {
// if both ciphers failed above we'll reach here
return fmt.Errorf("failed to decrypt seal: %w", err)
}
if seal.Kind != 13 {
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
}
if !seal.VerifySignature() {
return fmt.Errorf("seal signature is invalid")
}
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
senderEncryptionPublicKeys[0] = theirEPub // put decoupled key first
}
// decrypt rumor (at this point we know what cipher is the one they encrypted to)
// (but we don't know if they have encrypted with their identity key or their decoupled key, so try both)
var rumor nostr.Event
err = nil
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
if senderEncryptionPublicKey == nostr.ZeroPK {
continue
}
switch s {
case 0:
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
case 1:
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
}
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
if thisErr != nil {
err = fmt.Errorf("failed to decrypt rumor: %w", thisErr)
continue
}
if thisErr := easyjson.Unmarshal([]byte(rumorj), &rumor); thisErr != nil {
err = fmt.Errorf("invalid rumor JSON: %w", thisErr)
continue
}
break
}
if rumor.ID == nostr.ZeroID && rumor.PubKey == nostr.ZeroPK && rumor.CreatedAt == 0 {
return fmt.Errorf("failed to decrypt rumor: %w", err)
}
rumor.PubKey = seal.PubKey
rumor.ID = rumor.GetID()
// output the unwrapped event (rumor)
stdout(rumor.String())
}
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)
}
func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) {
relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok {
var ePub nostr.PubKey
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return [32]byte{}, true, fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
}
// check if we have the key
eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex())
if data, err := os.ReadFile(eKeyPath); err == nil {
eSec, err := nostr.SecretKeyFromHex(string(data))
if err != nil {
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
}
return eSec, true, nil
}
}
return [32]byte{}, false, nil
}
func getDecoupledEncryptionPublicKey(ctx context.Context, pubkey nostr.PubKey) (nostr.PubKey, bool) {
relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok {
var ePub nostr.PubKey
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return nostr.ZeroPK, false
}
return ePub, true
}
return nostr.ZeroPK, false
}

2869
git.go Normal file

File diff suppressed because it is too large Load Diff

95
go.mod
View File

@@ -1,88 +1,113 @@
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-20250522115245-f38ce069a93d
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.4
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-tty v0.0.7
github.com/mattn/go-isatty v0.0.20
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-20250506013437-ce4c2cf36ca6
golang.org/x/term v0.30.0
github.com/winfsp/cgofuse v1.6.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 (
fiatjaf.com/lib v0.3.7
github.com/hanwen/go-fuse/v2 v2.9.0
github.com/itchyny/gojq v0.12.19
github.com/mattn/go-tty/v2 v2.0.0
)
require (
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/PowerDNS/lmdb-go v1.9.3 // 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/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/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/cloudwego/base64x v0.1.5 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
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/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.3.1-0.20250123162555-7c0381a585e3 // 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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v24.12.23+incompatible // 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/itchyny/timefmt-go v0.1.8 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // 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.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // 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.1.1 // indirect
github.com/tidwall/match v1.2.0 // indirect
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.59.0 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // 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
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
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect
)

295
go.sum
View File

@@ -1,22 +1,36 @@
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=
fiatjaf.com/nostr v0.0.0-20250522115245-f38ce069a93d h1:sl/BOXW5eK7v+cchMMEZvnzQW+n/jWiHGQn+CRt5m5Q=
fiatjaf.com/nostr v0.0.0-20250522115245-f38ce069a93d/go.mod h1:VPs38Fc8J1XAErV750CXAmMUqIq3XEX9VZVj/LuQzzM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86 h1:p3HnX1UDT/CfiMvTc4yTcxHQm08ri7DM32P1uKkFNKg=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
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=
@@ -24,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.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
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,29 +57,40 @@ 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.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/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=
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/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/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
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=
@@ -77,25 +102,19 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/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/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
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=
github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3pShXg=
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/elnosh/gonuts v0.4.2 h1:/WubPAWGxTE+okJ0WPvmtEzTzpi04RGxiTHAF1FYU+M=
github.com/elnosh/gonuts v0.4.2/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs=
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=
@@ -104,44 +123,42 @@ 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/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/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.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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/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=
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
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/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
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/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY=
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -151,39 +168,57 @@ 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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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-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/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-tty/v2 v2.0.0 h1:AgXDfbKcENaiQbjdQ+o2ZusbtiaOhK1ArFD75d5U6qk=
github.com/mattn/go-tty/v2 v2.0.0/go.mod h1:azVwsnH46TUJRQPRp/IrUT+8nRkkvjvkESJJxAA4Y48=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
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.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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=
@@ -197,36 +232,41 @@ 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/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/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=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg=
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=
@@ -237,46 +277,47 @@ 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=
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
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=
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=
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=
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.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/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-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-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-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-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/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.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/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=
@@ -284,48 +325,39 @@ 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.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.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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
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-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/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/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/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=
@@ -336,6 +368,5 @@ 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=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

1102
group.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,24 +7,27 @@ import (
"fmt"
"iter"
"math/rand"
"net/http"
"net/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"unicode"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"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/mattn/go-tty"
"github.com/mattn/go-isatty"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
@@ -44,8 +47,14 @@ var (
)
func isPiped() bool {
stat, _ := os.Stdin.Stat()
return stat.Mode()&os.ModeCharDevice == 0
stat, err := os.Stdin.Stat()
if err != nil {
panic(err)
}
mode := stat.Mode()
is := mode&os.ModeCharDevice == 0
return is
}
func getJsonsOrBlank() iter.Seq[string] {
@@ -53,6 +62,7 @@ func getJsonsOrBlank() iter.Seq[string] {
var finalJsonErr error
return func(yield func(string) bool) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
// we'll try the next line until we're successful
@@ -67,6 +77,7 @@ func getJsonsOrBlank() iter.Seq[string] {
finalJsonErr = nil
if !yield(stdinEvent) {
stopped = true
return false
}
@@ -74,8 +85,14 @@ func getJsonsOrBlank() iter.Seq[string] {
return true
})
if stopped {
return
}
if !hasStdin {
yield("{}")
if !yield("{}") {
return
}
}
if finalJsonErr != nil {
@@ -86,15 +103,23 @@ func getJsonsOrBlank() iter.Seq[string] {
func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
stopped = true
return false
}
return true
})
if stopped {
return
}
if !hasStdin {
yield("")
if !yield("") {
return
}
}
}
}
@@ -161,23 +186,17 @@ func connectToAllRelays(
c *cli.Command,
relayUrls []string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error), // if this exists we will force preauth
opts nostr.PoolOptions,
) []*nostr.Relay {
// first pass to check if these are valid relay URLs
for _, url := range relayUrls {
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
for i, url := range relayUrls {
url = nostr.NormalizeURL(url)
relayUrls[i] = url
if !nostr.IsValidRelayURL(url) {
log("invalid relay URL: %s\n", url)
os.Exit(4)
}
}
opts.EventMiddleware = sys.TrackEventHints
opts.PenaltyBox = true
opts.RelayOptions = nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}},
}
sys.Pool = nostr.NewPool(opts)
relays := make([]*nostr.Relay, 0, len(relayUrls))
if supportsDynamicMultilineMagic() {
@@ -222,7 +241,7 @@ func connectToAllRelays(
} else {
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(url))
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
if relay != nil {
relays = append(relays, relay)
@@ -253,7 +272,7 @@ func connectToSingleRelay(
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" {
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
@@ -315,6 +334,10 @@ func supportsDynamicMultilineMagic() bool {
return false
}
if !isatty.IsTerminal(os.Stdout.Fd()) {
return false
}
width, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil {
return false
@@ -403,19 +426,6 @@ func clampError(err error, prefixAlreadyPrinted int) string {
return msg
}
func appendUnique[A comparable](list []A, newEls ...A) []A {
ex:
for _, newEl := range newEls {
for _, el := range list {
if el == newEl {
continue ex
}
}
list = append(list, newEl)
}
return list
}
func askConfirmation(msg string) bool {
if isPiped() {
tty, err := tty.Open()
@@ -424,7 +434,7 @@ func askConfirmation(msg string) bool {
}
defer tty.Close()
fmt.Fprintf(os.Stderr, color.YellowString(msg))
log(color.YellowString(msg))
answer, err := tty.ReadString()
if err != nil {
return false
@@ -459,22 +469,167 @@ func askConfirmation(msg string) bool {
}
}
func parsePubKey(value string) (nostr.PubKey, error) {
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err != nil {
return nostr.ZeroPK, err
}
return pp.PublicKey, nil
}
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, letter rune) string {
letter = unicode.ToLower(letter)
if letter == 'p' {
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err == nil {
return pp.PublicKey.Hex()
}
}
}
if (letter == 'p' && (strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nprofile1"))) ||
((letter == 'a' || letter == 'q') && strings.HasPrefix(value, "naddr1")) ||
((letter == 'e' || letter == 'q') && (strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1"))) {
if ptr, err := nip19.ToPointer(value); err == nil {
return ptr.AsTagReference()
}
}
return value
}
func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) {
fullpath := filepath.Join(os.TempDir(), filename)
if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}
if wipe {
if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("failed to remove temp file: %w", err)
}
}
tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
if _, err := tmp.WriteString(initialContent); err != nil {
tmp.Close()
return "", fmt.Errorf("failed to write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("failed to close temp file: %w", err)
}
editor := strings.TrimSpace(os.Getenv("VISUAL"))
if editor == "" {
editor = strings.TrimSpace(os.Getenv("EDITOR"))
}
if editor == "" {
editor = "edit"
}
parts := strings.Fields(editor)
if len(parts) == 0 {
return "", fmt.Errorf("failed to parse editor command '%s'", editor)
}
args := append(parts[1:], tmp.Name())
cmd := exec.Command(parts[0], args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("editor command failed: %w", err)
}
data, err := os.ReadFile(tmp.Name())
if err != nil {
return "", fmt.Errorf("failed to read edited temp file: %w", err)
}
return string(data), nil
}
func clampWithEllipsis(s string, size int) string {
if len(s) <= size {
return s
}
return s[0:size-1] + "…"
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
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
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
underline func(...any) string
underlinef 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.Underline).Sprint,
color.New(color.Underline).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,

View File

@@ -13,18 +13,20 @@ import (
"fiatjaf.com/nostr/nip49"
"github.com/chzyer/readline"
"github.com/fatih/color"
"github.com/mattn/go-tty"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
)
var defaultKey = nostr.KeyOne.Hex()
var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
DefaultText: "the key '1'",
DefaultText: "the key '01'",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: nostr.KeyOne.Hex(),
Value: defaultKey,
HideDefault: true,
},
&cli.BoolFlag{
@@ -75,9 +77,14 @@ func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (
clientKey = nostr.Generate()
}
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)
})
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("failed to connect to %s: %w", bunkerURL, err)
}
return nostr.SecretKey{}, bunker, err
}
@@ -139,7 +146,7 @@ func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, e
defer tty.Close()
for {
// print the prompt to stderr so it's visible to the user
fmt.Fprintf(os.Stderr, color.YellowString(msg))
log(color.YellowString(msg))
// read password from TTY with masking
password, err := tty.ReadPassword()

73
install.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env sh
set -e
# detect OS
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux";;
Darwin*) echo "darwin";;
FreeBSD*) echo "freebsd";;
MINGW*|MSYS*|CYGWIN*) echo "windows";;
*)
echo "error: unsupported OS $(uname -s)" >&2
exit 1
;;
esac
}
# detect architecture
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64";;
aarch64|arm64) echo "arm64";;
riscv64) echo "riscv64";;
*)
echo "error: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
}
# set install directory
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# detect platform
OS=$(detect_os)
ARCH=$(detect_arch)
echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
# check if curl is available
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
# get latest release tag
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
TAG="${RELEASE_INFO#*\"tag_name\"}"
TAG="${TAG#*\"}"
TAG="${TAG%%\"*}"
[ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
# construct download URL
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
# create install directory and download
mkdir -p "$INSTALL_DIR"
TARGET_PATH="$INSTALL_DIR/nak"
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
chmod +x "$TARGET_PATH"
echo "installed nak $TAG to $TARGET_PATH"
# check if install dir is in PATH
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "note: add $INSTALL_DIR to your PATH" ;;
esac
else
echo "error: download failed from $DOWNLOAD_URL" >&2
exit 1
fi

83
jq.go Normal file
View File

@@ -0,0 +1,83 @@
package main
import (
"fmt"
"fiatjaf.com/nostr"
"github.com/itchyny/gojq"
)
const eventJQPrelude = `
def tags(tagName): .tags | map(select(.[0] == tagName));
def tag(tagName): tags(tagName) | .[0];
def value(tagName): tag(tagName)[1];
def has(tagName): (tags(tagName) | length) > 0;
def hasnt(tagName): (tags(tagName) | length) == 0;
def has_value(tagName; tagValue): tags(tagName) | map(select(.[1] == tagValue)) | length > 0;
`
type jqProcessor func(nostr.Event) (any, bool, error)
func jqPrepare(expr string) (jqProcessor, error) {
if expr == "" {
return nil, nil
}
query, err := gojq.Parse(eventJQPrelude + expr)
if err != nil {
return nil, fmt.Errorf("invalid jq expression: %w", err)
}
code, err := gojq.Compile(query)
if err != nil {
return nil, fmt.Errorf("failed to compile jq expression: %w", err)
}
return func(evt nostr.Event) (any, bool, error) {
input, err := toJQInput(evt)
if err != nil {
return nil, false, err
}
iter := code.Run(input)
for {
v, ok := iter.Next()
if !ok {
return v, false, nil
}
if err, ok := v.(error); ok {
return v, false, err
}
if jqTruthy(v) {
return v, true, nil
}
}
}, nil
}
func toJQInput(v any) (any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("failed to marshal jq input: %w", err)
}
var input any
if err := json.Unmarshal(data, &input); err != nil {
return nil, fmt.Errorf("failed to unmarshal jq input: %w", err)
}
return input, nil
}
func jqTruthy(v any) bool {
switch v := v.(type) {
case nil:
return false
case bool:
return v
default:
return true
}
}

116
key.go
View File

@@ -23,9 +23,11 @@ var key = &cli.Command{
Commands: []*cli.Command{
generate,
public,
expand,
encryptKey,
decryptKey,
combine,
validate,
},
}
@@ -67,6 +69,37 @@ var public = &cli.Command{
},
}
var expand = &cli.Command{
Name: "expand",
Usage: "left-pads a hex key to 64 characters",
Description: ``,
ArgsUsage: "[key]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for keyhex := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
keyhex = strings.TrimSpace(keyhex)
if keyhex == "" {
continue
}
if len(keyhex) > 64 {
ctx = lineProcessingError(ctx, "invalid hex key: length %d", len(keyhex))
continue
}
check := keyhex
if len(check)%2 == 1 {
check = "0" + check
}
if _, err := hex.DecodeString(check); err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
keyhex = strings.ToLower(keyhex)
stdout(strings.Repeat("0", 64-len(keyhex)) + keyhex)
}
return nil
},
}
var encryptKey = &cli.Command{
Name: "encrypt",
Usage: "encrypts a secret key and prints an ncryptsec code",
@@ -155,6 +188,53 @@ var decryptKey = &cli.Command{
},
}
var validate = &cli.Command{
Name: "validate",
Usage: "validates a public key by attempting to parse it",
Description: `Accepts public keys in hex (64 characters) or npub format.
Returns error if key is invalid, otherwise exits successfully.`,
ArgsUsage: "[pubkey...]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for pk := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
pk = strings.TrimSpace(pk)
if pk == "" {
continue
}
var pkBytes []byte
var err error
if strings.HasPrefix(pk, "npub1") {
_, data, err := nip19.Decode(pk)
if err != nil {
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
continue
}
tmp := data.([32]byte)
pkBytes = tmp[:]
} else {
pkBytes, err = hex.DecodeString(pk)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex: %s", err)
continue
}
if len(pkBytes) != 32 {
ctx = lineProcessingError(ctx, "invalid pubkey length: expected 32 bytes, got %d", len(pkBytes))
continue
}
}
_, err = btcec.ParsePubKey(append([]byte{0x02}, pkBytes...))
if err != nil {
ctx = lineProcessingError(ctx, "invalid pubkey: %s", err)
continue
}
}
return nil
},
}
var combine = &cli.Command{
Name: "combine",
Usage: "combines two or more pubkeys using musig2",
@@ -183,15 +263,13 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
for i, keyhex := range result.Keys {
keyb, err := hex.DecodeString(keyhex)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
pk32, err := parsePubKey(keyhex)
if err == nil { /* we'll try both the 02 and the 03 prefix versions */
result.Keys[i] = pk32.Hex()
if len(keyb) == 32 /* we'll use both the 02 and the 03 prefix versions */ {
group := make([]*btcec.PublicKey, 2)
for i, prefix := range []byte{0x02, 0x03} {
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, pk32[:]...))
if err != nil {
log("error parsing key %s: %s", keyhex, err)
continue
@@ -199,7 +277,16 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
group[i] = pubk
}
keyGroups = append(keyGroups, group)
} else /* assume it's 33 */ {
continue
}
keyb, err := hex.DecodeString(keyhex)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
if len(keyb) == 33 {
result.Keys[i] = hex.EncodeToString(keyb[1:])
pubk, err := btcec.ParsePubKey(keyb)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
@@ -208,6 +295,8 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
// remove the leading byte from the output just so it is all uniform
result.Keys[i] = result.Keys[i][2:]
continue
}
}
@@ -279,12 +368,13 @@ func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, key
continue
}
sk = data.(nostr.SecretKey)
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
} else {
var err error
sk, err = nostr.SecretKeyFromHex(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
}
ch <- sk

45
lmdb.go Normal file
View File

@@ -0,0 +1,45 @@
//go:build linux && !riscv64 && !arm64
package main
import (
"os"
"path/filepath"
"fiatjaf.com/nostr/eventstore/lmdb"
"fiatjaf.com/nostr/eventstore/nullstore"
"fiatjaf.com/nostr/sdk"
"fiatjaf.com/nostr/sdk/hints/lmdbh"
lmdbkv "fiatjaf.com/nostr/sdk/kvstore/lmdb"
"github.com/urfave/cli/v3"
)
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
configPath := c.String("config-path")
if configPath == "" {
return
}
hintsPath := filepath.Join(configPath, "outbox/hints")
os.MkdirAll(hintsPath, 0755)
_, err := lmdbh.NewLMDBHints(hintsPath)
if err != nil {
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
}
eventsPath := filepath.Join(configPath, "events")
os.MkdirAll(eventsPath, 0755)
sys.Store = &lmdb.LMDBBackend{Path: eventsPath}
if err := sys.Store.Init(); err != nil {
log("failed to create boltdb events db at '%s': %s\n", eventsPath, err)
sys.Store = &nullstore.NullStore{}
}
kvPath := filepath.Join(configPath, "kvstore")
os.MkdirAll(kvPath, 0755)
if kv, err := lmdbkv.NewStore(kvPath); err != nil {
log("failed to create boltdb kvstore db at '%s': %s\n", kvPath, err)
} else {
sys.KVStore = kv
}
}

55
main.go
View File

@@ -2,10 +2,10 @@ package main
import (
"context"
"fmt"
"net/http"
"net/textproto"
"os"
"path/filepath"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk"
@@ -25,8 +25,9 @@ var app = &cli.Command{
Usage: "the nostr army knife command-line tool",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
event,
eventCmd,
req,
filterCmd,
fetch,
count,
decode,
@@ -34,23 +35,41 @@ var app = &cli.Command{
key,
verify,
relay,
admin,
bunker,
serve,
blossomCmd,
dekey,
encrypt,
decrypt,
outbox,
gift,
outboxCmd,
wallet,
mcpServer,
curl,
fsCmd,
publish,
nsite,
git,
group,
nip,
syncCmd,
spell,
profile,
validateCmd,
},
Version: version,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config-path",
Hidden: true,
Value: (func() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config/nak")
} else {
return ""
}
})(),
},
&cli.BoolFlag{
Name: "quiet",
@@ -84,17 +103,13 @@ var app = &cli.Command{
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
sys = sdk.NewSystem()
if err := initializeOutboxHintsDB(c, sys); err != nil {
return ctx, fmt.Errorf("failed to initialize outbox hints: %w", err)
}
setupLocalDatabases(c, sys)
sys.Pool = nostr.NewPool(nostr.PoolOptions{
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
EventMiddleware: sys.TrackEventHints,
RelayOptions: nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
},
})
sys.Pool.QueryMiddleware = sys.TrackQueryAttempts
sys.Pool.EventMiddleware = sys.TrackEventHints
sys.Pool.RelayOptions = nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
}
return ctx, nil
},
@@ -105,6 +120,12 @@ func init() {
Name: "version",
Usage: "prints the version",
}
cli.HelpFlag = &cli.BoolFlag{
Name: "help",
Usage: "shows help",
HideDefault: true,
Local: true,
}
}
func main() {
@@ -113,9 +134,7 @@ func main() {
// a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil {
if err != nil {
log(color.YellowString(err.Error()) + "\n")
}
log(color.YellowString(err.Error()) + "\n")
colors.reset()
os.Exit(1)
}
@@ -123,9 +142,7 @@ func main() {
}
if err := app.Run(context.Background(), os.Args); err != nil {
if err != nil {
log(color.YellowString(err.Error()) + "\n")
}
log("%s\n", color.RedString(err.Error()))
colors.reset()
os.Exit(1)
}

151
mcp.go
View File

@@ -6,6 +6,7 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/mark3labs/mcp-go/mcp"
@@ -33,10 +34,10 @@ var mcpServer = &cli.Command{
}
s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
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")),
mcp.WithDescription("publish a short note event to Nostr with the given text content"),
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")
@@ -105,7 +106,7 @@ 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.WithDescription("resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
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")
@@ -136,54 +137,67 @@ var mcpServer = &cli.Command{
WithRelays: false,
})
if err != nil {
return mcp.NewToolResultError("Couldn't find this event anywhere"), nil
return mcp.NewToolResultError("couldn't find this event anywhere"), nil
}
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr event: %s", event),
), nil
case "naddr":
return mcp.NewToolResultError("For now we can't handle this kind of Nostr uri"), nil
return mcp.NewToolResultError("for now we can't handle this kind of Nostr uri"), nil
default:
return mcp.NewToolResultError("We don't know how to handle this Nostr uri"), nil
return mcp.NewToolResultError("we don't know how to handle this Nostr uri"), nil
}
})
s.AddTool(mcp.NewTool("search_profile",
mcp.WithDescription("Search for the public key of a Nostr user given their name"),
mcp.WithString("name", mcp.Description("Name to be searched"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("How many results to return")),
mcp.WithDescription("search for the public key of a Nostr user given their name"),
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)
}
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))
res.Grow(500)
res.WriteString("search results: ")
if l >= int(limit) {
break
// check if input is already a valid pubkey
if pubkey, err := nostr.PubKeyFromHex(name); err == nil {
pm := sys.FetchProfileMetadata(ctx, pubkey)
res.WriteString(fmt.Sprintf("\n\nResult 1\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
pm.ShortName(), pm.PubKey.Hex(), pm.About))
} else {
// otherwise try to search
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
if limit > 0 {
filter.Limit = int(limit)
}
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine", "search.nos.social"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
pm, _ := sdk.ParseMetadata(result.Event)
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
}
}
if l == 0 {
return mcp.NewToolResultError("Couldn't find anyone with that name."), nil
}
return mcp.NewToolResultText(res.String()), nil
})
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.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
mcp.WithDescription("get the best relay from where to read notes from a specific Nostr user"),
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 {
@@ -195,7 +209,7 @@ var mcpServer = &cli.Command{
})
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.WithDescription("makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
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()),
@@ -219,7 +233,9 @@ var mcpServer = &cli.Command{
}
}
events := sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{})
events := sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-profile-events",
})
result := strings.Builder{}
for ie := range events {
@@ -234,6 +250,77 @@ var mcpServer = &cli.Command{
return mcp.NewToolResultText(result.String()), nil
})
s.AddTool(mcp.NewTool("search_events",
mcp.WithDescription("search for Nostr events. specifying the author makes it so we'll try to use their relays instead of generic ones."),
mcp.WithString("search", mcp.Description("search query string"), mcp.Required()),
mcp.WithString("author", mcp.Description("author public key to filter by")),
mcp.WithNumber("kind", mcp.Description("event kind to filter by")),
mcp.WithNumber("limit", mcp.Description("maximum number of results to return")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
search := required[string](r, "search")
author, hasAuthor := optional[string](r, "author")
kind, hasKind := optional[float64](r, "kind")
limit, _ := optional[float64](r, "limit")
if limit == 0 {
limit = 50
}
filter := nostr.Filter{Search: search, Limit: int(limit)}
if hasKind {
filter.Kinds = []nostr.Kind{nostr.Kind(int(kind))}
}
var relays []string
if hasAuthor {
if pk, err := nostr.PubKeyFromHex(author); err != nil {
return mcp.NewToolResultError("the author given isn't a valid public key, it must be 32 bytes hex. Got error: " + err.Error()), nil
} else {
filter.Authors = append(filter.Authors, pk)
}
pk, _ := nostr.PubKeyFromHex(author)
writeRelays := sys.FetchOutboxRelays(ctx, pk, 5)
for _, relayURL := range writeRelays {
if info, err := nip11.Fetch(ctx, relayURL); err == nil {
for _, nip := range info.SupportedNIPs {
if nipInt, ok := nip.(float64); ok && nipInt == 50 {
relays = append(relays, relayURL)
break
}
}
}
}
}
if len(relays) == 0 {
relays = []string{"relay.nostr.band", "nostr.polyserv.xyz/", "search.nos.today/"}
}
result := strings.Builder{}
result.WriteString(fmt.Sprintf("search results for '%s':\n\n", search))
l := 0
for event := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
result.WriteString(fmt.Sprintf("result %d\nID: %s\nKind: %d\nAuthor: %s\nContent: %s\n---\n",
l, event.ID, event.Kind, event.PubKey.Hex(), event.Content))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("no events found matching the search criteria."), nil
}
return mcp.NewToolResultText(result.String()), nil
})
return server.ServeStdio(s)
},
}

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
}

11
non_lmdb.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build !linux || riscv64 || arm64
package main
import (
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
}

View File

@@ -175,7 +175,7 @@ func (r *NostrRoot) CreateEventDir(
if event.Kind == 1 {
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(*pointer)
nevent := nip19.EncodePointer(pointer)
h.AddChild("@root", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
@@ -185,7 +185,7 @@ func (r *NostrRoot) CreateEventDir(
), true)
}
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(*pointer)
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{

1177
nostrfs_cgo/root.go Normal file

File diff suppressed because it is too large Load Diff

309
nsite.go Normal file
View File

@@ -0,0 +1,309 @@
package main
import (
"context"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip5a"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var nsite = &cli.Command{
Name: "nsite",
Suggest: true,
Usage: "publishes and downloads nip-5A static sites",
ArgsUsage: "<directory> [relay...]",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "upload",
Usage: "uploads site files and publishes manifest event",
ArgsUsage: "<directory> [relay...]",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "root",
Usage: "publish root site as kind 15128",
},
&cli.StringFlag{
Name: "identifier",
Aliases: []string{"d"},
Usage: "publish named site as kind 35128 with this d tag",
},
&cli.StringFlag{
Name: "description",
Usage: "a human-readable description of the site",
},
&cli.StringFlag{
Name: "source",
Usage: "a link to the source code of the site",
},
&cli.StringSliceFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "blossom server hostname or URL, can be given multiple times",
DefaultText: "defaults to the publisher's list of preferred blossom servers",
},
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
Usage: "skip upload confirmation prompt",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
dir := c.Args().First()
if dir == "" {
return fmt.Errorf("missing directory")
}
st, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", dir, err)
}
if !st.IsDir() {
return fmt.Errorf("%s is not a directory", dir)
}
root := c.Bool("root")
identifier := c.String("identifier")
if root == (identifier != "") {
return fmt.Errorf("pick exactly one of --root or --identifier/-d")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get public key: %w", err)
}
manifest := nip5a.SiteManifest{
Pubkey: pk,
Root: root,
Identifier: identifier,
Paths: make(map[string][32]byte),
Description: c.String("description"),
Source: c.String("source"),
}
blossomServers := c.StringSlice("server")
if len(blossomServers) != 0 {
manifest.Servers = blossomServers
} else {
servers := sys.FetchBlossomServerList(ctx, pk)
if len(servers.Items) == 0 {
return fmt.Errorf("no blossom servers advertised in manifest or kind:10063")
}
blossomServers = make([]string, len(servers.Items))
for i, s := range servers.Items {
blossomServers[i] = s.Value()
}
}
if !c.Bool("yes") {
log("%s\n", color.CyanString("files:"))
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
relPath, err := filepath.Rel(dir, path)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
}
log(" %s\n", color.GreenString("/%s", filepath.ToSlash(relPath)))
return nil
}); err != nil {
return err
}
log("%s\n", color.CyanString("blossom servers:"))
for _, server := range blossomServers {
log(" %s\n", color.YellowString(server))
}
if !askConfirmation("upload nsite and publish manifest? [y/n] ") {
return fmt.Errorf("aborted")
}
}
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
relPath, err := filepath.Rel(dir, path)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
}
var hhash string
for _, server := range blossomServers {
client := blossom.NewClient(server, kr)
bd, err := client.UploadFilePath(ctx, path)
if err != nil {
return fmt.Errorf("failed to upload %s to %s: %w", path, server, err)
}
hhash = bd.SHA256
log("uploaded %s to %s as %s\n", color.GreenString(path), color.YellowString(server), color.CyanString(hhash))
}
var hash [32]byte
if _, err := hex.Decode(hash[:], []byte(hhash)); err != nil {
return fmt.Errorf("invalid blob hash '%s': %w", hhash, err)
}
manifest.Paths["/"+filepath.ToSlash(relPath)] = hash
return nil
}); err != nil {
return err
}
evt := manifest.ToEvent()
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("error signing manifest event: %w", err)
}
relayURLs := nostr.AppendUnique(sys.FetchWriteRelays(ctx, pk), c.Args().Slice()[1:]...)
if len(relayURLs) == 0 {
return fmt.Errorf("no relays to publish this nsite to")
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(string, ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayURLs, nil)
if len(relays) == 0 {
return fmt.Errorf("failed to connect to any of [ %v ]", relayURLs)
}
stdout(evt.String())
if identifier == "" {
stdout(nip19.EncodeNpub(pk))
} else {
stdout(nip5a.PubKeyToBase36(pk) + identifier)
}
return publishFlow(ctx, c, kr, evt, relays)
},
},
{
Name: "download",
Usage: "downloads all files from a published nsite",
ArgsUsage: "<site> [directory]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
input := c.Args().First()
if input == "" {
return fmt.Errorf("missing site")
}
outputDir := c.Args().Get(1)
if outputDir == "" {
return fmt.Errorf("missing write directory")
}
if st, err := os.Stat(outputDir); err == nil {
if st.IsDir() {
return fmt.Errorf("output directory %s already exists", outputDir)
}
return fmt.Errorf("output path %s already exists and is not a directory", outputDir)
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat output directory %s: %w", outputDir, err)
}
pk, identifier, isRoot, err := nip5a.DecodeSiteURL(input)
if err != nil {
return err
}
filter := nostr.Filter{
Authors: []nostr.PubKey{pk},
Limit: 1,
}
if isRoot {
filter.Kinds = []nostr.Kind{nostr.KindNsiteRoot}
} else {
filter.Kinds = []nostr.Kind{nostr.KindNsiteNamed}
filter.Tags = nostr.TagMap{"d": []string{identifier}}
}
res := sys.Pool.QuerySingle(ctx, sys.FetchWriteRelays(ctx, pk), filter, nostr.SubscriptionOptions{
Label: "nak-nsite",
})
if res == nil {
return fmt.Errorf("failed to fetch nsite with filter %v", filter)
}
mnf, err := nip5a.ParseSiteManifest(&res.Event)
if err != nil {
return fmt.Errorf("invalid nsite %s: %w", res.Event, err)
}
blossomServers := mnf.Servers
if len(blossomServers) == 0 {
servers := sys.FetchBlossomServerList(ctx, res.Event.PubKey)
if len(servers.Items) == 0 {
return fmt.Errorf("no blossom servers advertised in manifest or kind:10063")
}
blossomServers = make([]string, len(servers.Items))
for i, s := range servers.Items {
blossomServers[i] = s.Value()
}
}
signer := keyer.NewReadOnlySigner(pk)
for path, hash := range mnf.Paths {
relPath := strings.TrimPrefix(path, "/")
if !filepath.IsLocal(relPath) {
return fmt.Errorf("manifest path %q escapes output directory", path)
}
fullPath := filepath.Join(outputDir, filepath.FromSlash(relPath))
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return fmt.Errorf("failed to create %s: %w", filepath.Dir(fullPath), err)
}
var downloadErr error
for _, server := range blossomServers {
client := blossom.NewClient(server, signer)
data, err := client.Download(ctx, hash)
if err != nil {
downloadErr = err
continue
}
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", fullPath, err)
}
stdout(path)
downloadErr = nil
break
}
if downloadErr != nil {
return fmt.Errorf("failed to download '%s': %w", path, downloadErr)
}
}
return nil
},
},
},
}

View File

@@ -3,91 +3,26 @@ package main
import (
"context"
"fmt"
"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"
)
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); err == nil {
hintsFileExists = true
} else if !os.IsNotExist(err) {
return err
}
}
if hintsFileExists && hintsFilePath != "" {
hintsdb, err := badgerh.NewBadgerHints(hintsFilePath)
if err == nil {
sys.Hints = hintsdb
}
}
return nil
}
var outbox = &cli.Command{
var outboxCmd = &cli.Command{
Name: "outbox",
Usage: "manage outbox relay hints database",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
{
Name: "init",
Usage: "initialize the outbox hints database",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if hintsFileExists {
return nil
}
if hintsFilePath == "" {
return fmt.Errorf("couldn't find a place to store the hints, pass --config-path to fix.")
}
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)
return nil
},
},
{
Name: "list",
Usage: "list outbox relays for a given pubkey",
ArgsUsage: "<pubkey>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if !hintsFileExists {
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)")
}
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)
}

102
profile.go Normal file
View File

@@ -0,0 +1,102 @@
package main
import (
"context"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var profile = &cli.Command{
Name: "profile",
Usage: "displays profile information for a given pubkey",
Description: `fetches and displays profile metadata, relays, and contact count for a given pubkey.
example usage:
nak profile npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps
nak profile user@example.com`,
ArgsUsage: "[pubkey]",
Action: func(ctx context.Context, c *cli.Command) error {
for pubkeyInput := range getStdinLinesOrArguments(c.Args()) {
pk, err := parsePubKey(pubkeyInput)
if err != nil {
ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err)
continue
}
pm := sys.FetchProfileMetadata(ctx, pk)
npub := nip19.EncodeNpub(pk)
stdout(colors.bold("pubkey (hex):"), pk.Hex())
stdout(colors.bold("npub:"), color.HiCyanString(npub))
relayList := sys.FetchRelayList(ctx, pk)
writeRelays := make([]string, 0, 3)
for _, rl := range relayList.Items {
if rl.Outbox {
writeRelays = append(writeRelays, rl.URL)
if len(writeRelays) == 3 {
break
}
}
}
if len(writeRelays) > 0 {
nprofile := nip19.EncodeNprofile(pk, writeRelays)
stdout(colors.bold("profile uri:"), color.HiCyanString("nostr:"+nprofile))
}
if pm.Name != "" {
stdout(colors.bold("name:"), color.HiBlueString(pm.Name))
}
if pm.DisplayName != "" {
stdout(colors.bold("display_name:"), color.HiBlueString(pm.DisplayName))
}
if pm.About != "" {
stdout(colors.bold("about:"), color.HiBlueString(pm.About))
}
if pm.Picture != "" {
stdout(colors.bold("picture:"), color.HiBlueString(pm.Picture))
}
if pm.Banner != "" {
stdout(colors.bold("banner:"), color.HiBlueString(pm.Banner))
}
if pm.Website != "" {
stdout(colors.bold("website:"), color.HiBlueString(pm.Website))
}
if pm.NIP05 != "" {
isValid := pm.NIP05Valid(ctx)
if isValid {
stdout(colors.bold("nip05:"), color.HiGreenString(pm.NIP05), color.HiGreenString("(verified)"))
} else {
stdout(colors.bold("nip05:"), color.HiRedString(pm.NIP05), color.HiRedString("(not verified)"))
}
}
if pm.LUD16 != "" {
stdout(colors.bold("lud16:"), color.HiBlueString(pm.LUD16))
}
if len(relayList.Items) > 0 {
stdout(colors.bold("relays:"))
for _, relay := range relayList.Items {
access := ""
if relay.Inbox && relay.Outbox {
access = "read/write"
} else if relay.Inbox {
access = "read"
} else if relay.Outbox {
access = "write"
}
stdout(" ", color.HiBlueString(relay.URL), color.HiCyanString("(%s)", access))
}
}
followList := sys.FetchFollowList(ctx, pk)
contactCount := len(followList.Items)
stdout(colors.bold("follows:"), color.HiCyanString("%d", contactCount))
}
exitIfLineProcessingError(ctx)
return nil
},
}

View File

@@ -91,6 +91,8 @@ example:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
case nostr.EntityPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
default:
return fmt.Errorf("unexpected reply target type: %T", value)
}
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
@@ -109,6 +111,7 @@ example:
if replyEvent.Kind != 1 {
evt.Kind = 1111
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
}
// add reply tags
@@ -151,13 +154,11 @@ example:
relayUrls = nostr.AppendUnique(relayUrls, targetRelays...)
relayUrls = nostr.AppendUnique(relayUrls, replyRelays...)
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
relays := connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},
)
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 {
if len(relayUrls) == 0 {

179
relay.go
View File

@@ -1,30 +1,19 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip86"
"github.com/urfave/cli/v3"
)
var relay = &cli.Command{
Name: "relay",
Usage: "gets the relay information document for the given relay, as JSON -- or allows usage of the relay management API.",
Description: `examples:
fetching relay information:
Usage: "gets the relay information document for the given relay, as JSON",
Description: `
nak relay nostr.wine
managing a relay
nak relay nostr.wine banevent --sec 1234 --id 037eb3751073770ff17483b1b1ff125866cd5147668271975ef0a8a8e7ee184a --reason "I don't like it"`,
`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
@@ -35,7 +24,7 @@ var relay = &cli.Command{
info, err := nip11.Fetch(ctx, url)
if err != nil {
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %w", url, err)
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %s", url, err)
continue
}
@@ -44,164 +33,4 @@ var relay = &cli.Command{
}
return nil
},
Commands: (func() []*cli.Command {
methods := []struct {
method string
args []string
}{
{"allowpubkey", []string{"pubkey", "reason"}},
{"banpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"allowpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"listeventsneedingmoderation", nil},
{"allowevent", []string{"id", "reason"}},
{"banevent", []string{"id", "reason"}},
{"listbannedevents", nil},
{"changerelayname", []string{"name"}},
{"changerelaydescription", []string{"description"}},
{"changerelayicon", []string{"icon"}},
{"allowkind", []string{"kind"}},
{"disallowkind", []string{"kind"}},
{"listallowedkinds", nil},
{"blockip", []string{"ip", "reason"}},
{"unblockip", []string{"ip", "reason"}},
{"listblockedips", nil},
}
commands := make([]*cli.Command, 0, len(methods))
for _, def := range methods {
def := def
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
for i, argName := range def.args {
flags[i] = declareFlag(argName)
}
flags = append(flags, defaultKeyFlags...)
cmd := &cli.Command{
Name: def.method,
Usage: fmt.Sprintf(`the "%s" relay management RPC call`, def.method),
Description: fmt.Sprintf(
`the "%s" management RPC call, see https://nips.nostr.com/86 for more information`, def.method),
Flags: flags,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
params := make([]any, len(def.args))
for i, argName := range def.args {
params[i] = getArgument(c, argName)
}
req := nip86.Request{Method: def.method, Params: params}
reqj, _ := json.Marshal(req)
relayUrls := c.Args().Slice()
if len(relayUrls) == 0 {
stdout(string(reqj))
return nil
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
for _, relayUrl := range relayUrls {
httpUrl := "http" + nostr.NormalizeURL(relayUrl)[2:]
log("calling '%s' on %s... ", def.method, httpUrl)
body := bytes.NewBuffer(nil)
body.Write(reqj)
req, err := http.NewRequestWithContext(ctx, "POST", httpUrl, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Authorization
payloadHash := sha256.Sum256(reqj)
tokenEvent := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", httpUrl},
{"method", "POST"},
{"payload", hex.EncodeToString(payloadHash[:])},
},
}
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
return fmt.Errorf("failed to sign token event: %w", err)
}
evtj, _ := json.Marshal(tokenEvent)
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
// Content-Type
req.Header.Set("Content-Type", "application/nostr+json+rpc")
// make request to relay
resp, err := http.DefaultClient.Do(req)
if err != nil {
log("failed: %s\n", err)
continue
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log("failed to read response: %s\n", err)
continue
}
if resp.StatusCode >= 300 {
log("failed with status %d\n", resp.StatusCode)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
var response nip86.Response
if err := json.Unmarshal(b, &response); err != nil {
log("bad json response: %s\n", err)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
resp.Body.Close()
// print the result
log("\n")
pretty, _ := json.MarshalIndent(response, "", " ")
stdout(string(pretty))
}
return nil
},
}
commands = append(commands, cmd)
}
return commands
})(),
}
func declareFlag(argName string) cli.Flag {
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
switch argName {
case "kind":
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
case "reason":
return &cli.StringFlag{Name: argName, Usage: usage}
default:
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
}
}
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "kind":
return c.Int(argName)
default:
return c.String(argName)
}
}

488
req.go
View File

@@ -1,17 +1,26 @@
package main
import (
"bufio"
"context"
"fmt"
"math"
"os"
"slices"
"strings"
"sync"
"time"
"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"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
"golang.org/x/sync/errgroup"
)
const (
@@ -35,6 +44,19 @@ example:
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "skip event signature verification from relays",
},
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
@@ -44,6 +66,17 @@ example:
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "outbox",
Usage: "read from \"write\" relays of \"authors\" and/or from the \"read\" relays of any \"#p\" or \"#P\" tags",
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
@@ -53,11 +86,6 @@ example:
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&cli.UintFlag{
Name: "paginate-global-limit",
Usage: "global limit at which --paginate should stop",
DefaultText: "uses the value given by --limit/-l or infinite",
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
@@ -72,37 +100,77 @@ example:
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "spell",
Usage: "output a spell event (kind 777) instead of a filter",
},
)...,
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
if c.Bool("no-verify") {
sys.Pool.RelayOptions.AssumeValid = true
}
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
if negentropy {
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
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")
}
if c.Bool("paginate") && c.Bool("outbox") {
return fmt.Errorf("incompatible flags --paginate and --outbox")
}
if c.Bool("bare") && c.Bool("spell") {
return fmt.Errorf("incompatible flags --bare and --spell")
}
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
if len(relayUrls) > 0 && (c.Bool("bare") || c.Bool("spell")) {
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
}
jq, err := jqPrepare(c.String("jq"))
if err != nil {
return err
}
if jq != nil && len(relayUrls) == 0 && !c.Bool("outbox") {
return fmt.Errorf("--jq requires relay URLs or --outbox")
}
if len(relayUrls) > 0 && !negentropy {
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
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"):]
}
log(s+"\n", args...)
}, authEvent)
},
})
)
// stop here already if all connections failed
if len(relays) == 0 {
@@ -129,45 +197,93 @@ example:
return err
}
if len(relayUrls) > 0 {
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 len(relayUrls) > 0 || c.Bool("outbox") {
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 {
if err == eventstore.ErrDupEvent {
continue
}
return fmt.Errorf("failed to save event from sync file: %w", err)
}
}
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},
jq: jq,
}
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 {
fn := sys.Pool.FetchMany
if c.Bool("paginate") {
fn = sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
} else if c.Bool("stream") {
fn = sys.Pool.SubscribeMany
}
for ie := range fn(ctx, relayUrls, filter, nostr.SubscriptionOptions{}) {
stdout(ie.Event)
if err := performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req", jq); err != nil {
return err
}
}
} else {
// no relays given, will just print the filter
// no relays given, will just print the filter or spell
var result string
if c.Bool("bare") {
if c.Bool("spell") {
// output a spell event instead of a filter
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
spellEvent := createSpellEvent(ctx, filter, kr)
j, _ := json.Marshal(spellEvent)
result = string(j)
} else if c.Bool("bare") {
// bare filter output
result = filter.String()
} else {
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filter: filter})
// normal filter
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
result = string(j)
}
}
stdout(result)
}
}
@@ -177,17 +293,214 @@ example:
},
}
func performReq(
ctx context.Context,
filter nostr.Filter,
relayUrls []string,
stream bool,
outbox bool,
outboxRelaysPerPubKey uint64,
paginate bool,
paginateInterval time.Duration,
label string,
jq jqProcessor,
) error {
var results chan nostr.RelayEvent
var closeds chan nostr.RelayClosed
opts := nostr.SubscriptionOptions{
Label: label,
}
if paginate {
paginator := sys.Pool.PaginatorWithInterval(paginateInterval)
results = paginator(ctx, relayUrls, filter, opts)
} else if outbox {
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
pTagPubkeys := make([]nostr.PubKey, 0, 4)
if len(filter.Authors) == 0 && filter.Tags != nil {
seen := make(map[nostr.PubKey]struct{})
pTags := append([]string{}, filter.Tags["p"]...)
pTags = append(pTags, filter.Tags["P"]...)
for _, value := range pTags {
pubkey, err := parsePubKey(value)
if err != nil {
continue
}
if _, ok := seen[pubkey]; ok {
continue
}
seen[pubkey] = struct{}{}
pTagPubkeys = append(pTagPubkeys, pubkey)
}
}
for _, relayUrl := range relayUrls {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: relayUrl,
})
}
if len(filter.Authors) == 0 && len(pTagPubkeys) > 0 {
// relays for p tags when no authors are given
errg := errgroup.Group{}
errg.SetLimit(16)
mu := sync.Mutex{}
logverbose("gathering inbox relays for %d p-tags...\n", len(pTagPubkeys))
for _, pubkey := range pTagPubkeys {
errg.Go(func() error {
n := int(outboxRelaysPerPubKey)
for _, url := range sys.FetchInboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already specified globally, ignore
continue
}
if !nostr.IsValidRelayURL(url) {
continue
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
mu.Lock()
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue
}
mu.Unlock()
}
}
return nil
})
}
errg.Wait()
} else {
// relays for each pubkey
errg := errgroup.Group{}
errg.SetLimit(16)
mu := sync.Mutex{}
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
for _, pubkey := range filter.Authors {
errg.Go(func() error {
n := int(outboxRelaysPerPubKey)
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already specified globally, ignore
continue
}
if !nostr.IsValidRelayURL(url) {
continue
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
mu.Lock()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue // done with this relay url
}
// otherwise we'll just use the idx
mu.Unlock()
}
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
return nil
})
}
errg.Wait()
}
if stream {
logverbose("running subscription with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
} else {
logverbose("running query with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
}
} else {
if stream {
logverbose("running subscription to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
} else {
logverbose("running query to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
}
}
readevents:
for {
select {
case ie, stillOpen := <-results:
if !stillOpen {
break readevents
}
var out string
if jq == nil {
out = ie.Event.String()
} else {
v, matches, err := jq(ie.Event)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if !matches {
continue
}
out, _ = json.MarshalToString(v)
}
stdout(out)
case closed, stillOpen := <-closeds:
if stillOpen {
if closed.HandledAuth {
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
} else {
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
}
}
case <-ctx.Done():
break readevents
}
}
return nil
}
var reqFilterFlags = []cli.Flag{
&PubKeySliceFlag{
&PubKeyOrAddressFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (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{
@@ -217,6 +530,11 @@ var reqFilterFlags = []cli.Flag{
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "h",
Usage: "shortcut for --tag h=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
@@ -242,10 +560,30 @@ var reqFilterFlags = []cli.Flag{
},
}
type flagTag struct {
key string
value string
}
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = append(filter.Authors, authors...)
tags := make([]flagTag, 0, 5)
if as := getPubKeyOrAddressSlice(c, "author"); len(as) > 0 {
for _, author := range as {
// is it an address?
if author.Addr != nil {
tags = append(tags, flagTag{"a", author.Addr.AsTagReference()})
continue
}
// or is it an "author" pubkey?
if author.PubKey != nostr.ZeroPK {
filter.Authors = append(filter.Authors, author.PubKey)
}
}
}
if ids := getIDSlice(c, "id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
@@ -255,23 +593,30 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
if search := c.String("search"); search != "" {
filter.Search = search
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
val := spl[1]
if len(spl[0]) == 1 {
val = decodeTagValue(val, rune(spl[0][0]))
}
tags = append(tags, flagTag{spl[0], val})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
tags = append(tags, flagTag{"e", decodeTagValue(etag, 'e')})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
tags = append(tags, flagTag{"p", decodeTagValue(ptag, 'p')})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", dtag})
tags = append(tags, flagTag{"d", dtag})
}
for _, htag := range c.StringSlice("h") {
tags = append(tags, flagTag{"h", htag})
}
if len(tags) > 0 && filter.Tags == nil {
@@ -279,10 +624,10 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
}
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
if _, ok := filter.Tags[tag.key]; !ok {
filter.Tags[tag.key] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
filter.Tags[tag.key] = append(filter.Tags[tag.key], tag.value)
}
if c.IsSet("since") {
@@ -300,3 +645,32 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
return nil
}
type PrintingQuerierPublisher struct {
nostr.QuerierPublisher
jq jqProcessor
}
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
var out string
if p.jq == nil {
out = evt.String()
} else {
v, matches, err := p.jq(evt)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if !matches {
return nil
}
out, _ = json.MarshalToString(v)
}
stdout(out)
return nil
} else if err == eventstore.ErrDupEvent {
return nil
} else {
return err
}
}

191
serve.go
View File

@@ -2,17 +2,27 @@ package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"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 +46,31 @@ 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.StringFlag{
Name: "grasp-path",
Usage: "where to store the repositories",
TakesFile: true,
Hidden: true,
},
&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,24 +102,138 @@ 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)
hostname := c.String("hostname")
port := int(c.Uint("port"))
go func() {
err := rl.Start(hostname, port, started)
exited <- err
}()
totalConnections := atomic.Int32{}
rl.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
d := debounce.New(time.Second * 2)
var printStatus func()
printStatus = func() {
d(func() {
totalEvents, err := db.CountEvents(nostr.Filter{})
if err != nil {
log("failed to count: %s\n", err)
}
subs := rl.GetListeningFilters()
blossomMsg := ""
if c.Bool("blossom") {
blobsStored := blobStore.Size()
blossomMsg = fmt.Sprintf("blobs: %s, ",
color.HiMagentaString("%d", blobsStored),
)
}
graspMsg := ""
if c.Bool("grasp") {
gitAnnounced := 0
gitStored := 0
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
gitAnnounced++
identifier := evt.Tags.GetD()
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
gitStored++
}
}
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
color.HiMagentaString("%d", gitAnnounced),
color.HiMagentaString("%d", gitStored),
)
}
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
color.HiMagentaString("•"),
color.HiMagentaString("%d", totalEvents),
blossomMsg,
graspMsg,
color.HiMagentaString("%d", totalConnections.Load()),
color.HiMagentaString("%d", len(subs)),
)
})
}
if c.Bool("blossom") {
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
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") {
repoDir = c.String("grasp-path")
if repoDir == "" {
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, ""
}
}
ln, err := net.Listen("tcp", net.JoinHostPort(hostname, strconv.Itoa(port)))
if err != nil {
return err
}
go func() {
exited <- http.Serve(ln, rl)
}()
// 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, ""
}
@@ -105,35 +250,11 @@ var serve = &cli.Command{
return false, ""
}
totalConnections := atomic.Int32{}
rl.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
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)
}
d := debounce.New(time.Second * 2)
printStatus = func() {
d(func() {
totalEvents, err := db.CountEvents(nostr.Filter{})
if err != nil {
log("failed to count: %s\n", err)
}
subs := rl.GetListeningFilters()
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)),
)
})
}
<-started
log("%s relay running at %s\n", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
log("\n")
return <-exited
},

536
spell.go Normal file
View File

@@ -0,0 +1,536 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk/hints"
"github.com/fatih/color"
"github.com/markusmobius/go-dateparser"
"github.com/urfave/cli/v3"
)
var spell = &cli.Command{
Name: "spell",
Usage: "downloads a spell event and executes its REQ request",
ArgsUsage: "[nevent_code]",
Description: `fetches a spell event (kind 777) and executes REQ command encoded in its tags.`,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "pub",
Usage: "public key to run spells in the context of (if you don't want to pass a --sec)",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
),
Action: func(ctx context.Context, c *cli.Command) error {
configPath := c.String("config-path")
os.MkdirAll(filepath.Join(configPath, "spells"), 0755)
// load history from file
var history []SpellHistoryEntry
historyPath := filepath.Join(configPath, "spells/history")
file, err := os.Open(historyPath)
if err == nil {
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var entry SpellHistoryEntry
if err := json.Unmarshal([]byte(scanner.Text()), &entry); err != nil {
continue // skip invalid entries
}
history = append(history, entry)
}
}
if c.Args().Len() == 0 {
// check if we have input from stdin
for stdinEvent := range getJsonsOrBlank() {
if stdinEvent == "{}" {
break
}
var spell nostr.Event
if err := json.Unmarshal([]byte(stdinEvent), &spell); err != nil {
return fmt.Errorf("failed to parse spell event from stdin: %w", err)
}
if spell.Kind != 777 {
return fmt.Errorf("event is not a spell (expected kind 777, got %d)", spell.Kind)
}
return runSpell(ctx, c, historyPath, history, nostr.EventPointer{ID: spell.ID}, spell)
}
// no stdin input, show recent spells
log("recent spells:\n")
for i, entry := range history {
if i >= 10 {
break
}
displayName := entry.Name
if displayName == "" {
displayName = clampWithEllipsis(entry.Content, 28)
}
if displayName != "" {
displayName = color.HiMagentaString(displayName) + ": "
}
desc := clampWithEllipsis(entry.Content, 50)
lastUsed := entry.LastUsed.Format("2006-01-02 15:04")
stdout(fmt.Sprintf(" %s %s%s - %s",
color.BlueString(entry.Identifier),
displayName,
color.YellowString(lastUsed),
desc,
))
}
return nil
}
// decode nevent to get the spell event
var pointer nostr.EventPointer
identifier := c.Args().First()
prefix, value, err := nip19.Decode(identifier)
if err == nil {
if prefix != "nevent" {
return fmt.Errorf("expected nevent code, got %s", prefix)
}
pointer = value.(nostr.EventPointer)
} else {
// search our history
for _, entry := range history {
if entry.Identifier == identifier || entry.Name == identifier {
pointer = entry.Pointer
break
}
}
}
if pointer.ID == nostr.ZeroID {
return fmt.Errorf("invalid spell reference")
}
// first try to fetch spell from sys.Store
var spell nostr.Event
found := false
for evt := range sys.Store.QueryEvents(nostr.Filter{IDs: []nostr.ID{pointer.ID}}, 1) {
spell = evt
found = true
break
}
var relays []string
if !found {
// if not found in store, fetch from external relays
relays = pointer.Relays
if pointer.Author != nostr.ZeroPK {
for _, url := range relays {
sys.Hints.Save(pointer.Author, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
}
relays = append(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...)
}
result := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}},
nostr.SubscriptionOptions{Label: "nak-spell-f"})
if result == nil {
return fmt.Errorf("spell event not found")
}
spell = result.Event
}
if spell.Kind != 777 {
return fmt.Errorf("event is not a spell (expected kind 777, got %d)", spell.Kind)
}
return runSpell(ctx, c, historyPath, history, pointer, spell)
},
}
func runSpell(
ctx context.Context,
c *cli.Command,
historyPath string,
history []SpellHistoryEntry,
pointer nostr.EventPointer,
spell nostr.Event,
) error {
// parse spell tags to build REQ filter
spellFilter, err := buildSpellReq(ctx, c, spell.Tags)
if err != nil {
return fmt.Errorf("failed to parse spell tags: %w", err)
}
// determine relays to query
var spellRelays []string
var outbox bool
relaysTag := spell.Tags.Find("relays")
if relaysTag == nil {
// if this tag doesn't exist assume $outbox
relaysTag = nostr.Tag{"relays", "$outbox"}
}
for i := 1; i < len(relaysTag); i++ {
switch relaysTag[i] {
case "$outbox":
outbox = true
default:
spellRelays = append(spellRelays, relaysTag[i])
}
}
stream := !spell.Tags.Has("close-on-eose")
// fill in the author if we didn't have it
pointer.Author = spell.PubKey
// save spell to sys.Store
if err := sys.Store.SaveEvent(spell); err != nil {
logverbose("failed to save spell to store: %v\n", err)
}
// add to history before execution
{
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
identifier := "spell" + idStr[len(idStr)-7:]
nameTag := spell.Tags.Find("name")
var name string
if nameTag != nil {
name = nameTag[1]
}
if len(history) > 100 {
history = history[:100]
}
// write back to file
file, err := os.Create(historyPath)
if err != nil {
return err
}
data, _ := json.Marshal(SpellHistoryEntry{
Identifier: identifier,
Name: name,
Content: spell.Content,
LastUsed: time.Now(),
Pointer: pointer,
})
file.Write(data)
file.Write([]byte{'\n'})
for i, entry := range history {
if entry.Identifier == identifier {
continue
}
data, _ := json.Marshal(entry)
file.Write(data)
file.Write([]byte{'\n'})
// limit history size (keep last 100)
if i == 100 {
break
}
}
file.Close()
logverbose("executing %s: %s relays=%v outbox=%v stream=%v\n",
identifier, spellFilter, spellRelays, outbox, stream)
}
// execute
logSpellDetails(spell)
if err := performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-spell", nil); err != nil {
return err
}
return nil
}
func buildSpellReq(ctx context.Context, c *cli.Command, tags nostr.Tags) (nostr.Filter, error) {
filter := nostr.Filter{}
getMe := func() (nostr.PubKey, error) {
if !c.IsSet("sec") && !c.IsSet("prompt-sec") && c.IsSet("pub") {
return parsePubKey(c.String("pub"))
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return nostr.ZeroPK, fmt.Errorf("failed to get keyer: %w", err)
}
pubkey, err := kr.GetPublicKey(ctx)
if err != nil {
return nostr.ZeroPK, fmt.Errorf("failed to get public key from keyer: %w", err)
}
return pubkey, nil
}
for _, tag := range tags {
if len(tag) == 0 {
continue
}
switch tag[0] {
case "cmd":
if len(tag) < 2 || tag[1] != "REQ" {
return nostr.Filter{}, fmt.Errorf("only REQ commands are supported")
}
case "k":
for i := 1; i < len(tag); i++ {
if kind, err := strconv.Atoi(tag[i]); err == nil {
filter.Kinds = append(filter.Kinds, nostr.Kind(kind))
}
}
case "authors":
for i := 1; i < len(tag); i++ {
switch tag[i] {
case "$me":
me, err := getMe()
if err != nil {
return nostr.Filter{}, err
}
filter.Authors = append(filter.Authors, me)
case "$contacts":
me, err := getMe()
if err != nil {
return nostr.Filter{}, err
}
for _, f := range sys.FetchFollowList(ctx, me).Items {
filter.Authors = append(filter.Authors, f.Pubkey)
}
default:
pubkey, err := nostr.PubKeyFromHex(tag[i])
if err != nil {
return nostr.Filter{}, fmt.Errorf("invalid pubkey '%s' in 'authors': %w", tag[i], err)
}
filter.Authors = append(filter.Authors, pubkey)
}
}
case "ids":
for i := 1; i < len(tag); i++ {
id, err := nostr.IDFromHex(tag[i])
if err != nil {
return nostr.Filter{}, fmt.Errorf("invalid id '%s' in 'authors': %w", tag[i], err)
}
filter.IDs = append(filter.IDs, id)
}
case "tag":
if len(tag) < 3 {
continue
}
tagName := tag[1]
if filter.Tags == nil {
filter.Tags = make(nostr.TagMap)
}
for i := 2; i < len(tag); i++ {
switch tag[i] {
case "$me":
me, err := getMe()
if err != nil {
return nostr.Filter{}, err
}
filter.Tags[tagName] = append(filter.Tags[tagName], me.Hex())
case "$contacts":
me, err := getMe()
if err != nil {
return nostr.Filter{}, err
}
for _, f := range sys.FetchFollowList(ctx, me).Items {
filter.Tags[tagName] = append(filter.Tags[tagName], f.Pubkey.Hex())
}
default:
filter.Tags[tagName] = append(filter.Tags[tagName], tag[i])
}
}
case "limit":
if len(tag) >= 2 {
if limit, err := strconv.Atoi(tag[1]); err == nil {
filter.Limit = limit
}
}
case "since":
if len(tag) >= 2 {
date, err := dateparser.Parse(&dateparser.Configuration{
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, tag[1])
if err != nil {
return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err)
}
filter.Since = nostr.Timestamp(date.Time.Unix())
}
case "until":
if len(tag) >= 2 {
date, err := dateparser.Parse(&dateparser.Configuration{
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, tag[1])
if err != nil {
return nostr.Filter{}, fmt.Errorf("invalid date %s: %w", tag[1], err)
}
filter.Until = nostr.Timestamp(date.Time.Unix())
}
case "search":
if len(tag) >= 2 {
filter.Search = tag[1]
}
}
}
return filter, nil
}
func parseRelativeTime(timeStr string) (nostr.Timestamp, error) {
// Handle special cases
switch timeStr {
case "now":
return nostr.Now(), nil
}
// Try to parse as relative time (e.g., "7d", "1h", "30m")
if strings.HasSuffix(timeStr, "d") {
days := strings.TrimSuffix(timeStr, "d")
if daysInt, err := strconv.Atoi(days); err == nil {
return nostr.Now() - nostr.Timestamp(daysInt*24*60*60), nil
}
} else if strings.HasSuffix(timeStr, "h") {
hours := strings.TrimSuffix(timeStr, "h")
if hoursInt, err := strconv.Atoi(hours); err == nil {
return nostr.Now() - nostr.Timestamp(hoursInt*60*60), nil
}
} else if strings.HasSuffix(timeStr, "m") {
minutes := strings.TrimSuffix(timeStr, "m")
if minutesInt, err := strconv.Atoi(minutes); err == nil {
return nostr.Now() - nostr.Timestamp(minutesInt*60), nil
}
}
// try to parse as direct timestamp
if ts, err := strconv.ParseInt(timeStr, 10, 64); err == nil {
return nostr.Timestamp(ts), nil
}
return 0, fmt.Errorf("invalid time format: %s", timeStr)
}
type SpellHistoryEntry struct {
Identifier string `json:"_id"`
Name string `json:"name,omitempty"`
Content string `json:"content,omitempty"`
LastUsed time.Time `json:"last_used"`
Pointer nostr.EventPointer `json:"pointer"`
}
func logSpellDetails(spell nostr.Event) {
nameTag := spell.Tags.Find("name")
name := ""
if nameTag != nil {
name = clampWithEllipsis(nameTag[1], 28)
}
if name != "" {
name = ": " + color.HiMagentaString(name)
}
desc := clampWithEllipsis(spell.Content, 50)
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
identifier := "spell" + idStr[len(idStr)-7:]
log("running %s%s - %s\n",
color.BlueString(identifier),
name,
desc,
)
}
func createSpellEvent(ctx context.Context, filter nostr.Filter, kr nostr.Keyer) nostr.Event {
spell := nostr.Event{
Kind: 777,
Tags: make(nostr.Tags, 0),
}
// add cmd tag
spell.Tags = append(spell.Tags, nostr.Tag{"cmd", "REQ"})
// add kinds
if len(filter.Kinds) > 0 {
kindTag := nostr.Tag{"k"}
for _, kind := range filter.Kinds {
kindTag = append(kindTag, strconv.Itoa(int(kind)))
}
spell.Tags = append(spell.Tags, kindTag)
}
// add authors
if len(filter.Authors) > 0 {
authorsTag := nostr.Tag{"authors"}
for _, author := range filter.Authors {
authorsTag = append(authorsTag, author.Hex())
}
spell.Tags = append(spell.Tags, authorsTag)
}
// add ids
if len(filter.IDs) > 0 {
idsTag := nostr.Tag{"ids"}
for _, id := range filter.IDs {
idsTag = append(idsTag, id.Hex())
}
spell.Tags = append(spell.Tags, idsTag)
}
// add tags
for tagName, values := range filter.Tags {
if len(values) > 0 {
tag := nostr.Tag{"tag", tagName}
for _, value := range values {
tag = append(tag, value)
}
spell.Tags = append(spell.Tags, tag)
}
}
// add limit
if filter.Limit > 0 {
spell.Tags = append(spell.Tags, nostr.Tag{"limit", strconv.Itoa(filter.Limit)})
}
// add since
if filter.Since > 0 {
spell.Tags = append(spell.Tags, nostr.Tag{"since", strconv.FormatInt(int64(filter.Since), 10)})
}
// add until
if filter.Until > 0 {
spell.Tags = append(spell.Tags, nostr.Tag{"until", strconv.FormatInt(int64(filter.Until), 10)})
}
// add search
if filter.Search != "" {
spell.Tags = append(spell.Tags, nostr.Tag{"search", filter.Search})
}
if err := kr.SignEvent(ctx, &spell); err != nil {
log("failed to sign spell: %s\n", err)
}
return spell
}

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, uint64(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, uint64(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, uint64(negentropy.FingerprintMode))
nextMsg.Write(fingerprint[:])
}
default:
return "", fmt.Errorf("unknown mode %v", mode)
}
}
return nostr.HexEncodeToString(nextMsg.Bytes()), nil
}

321
thread_helpers.go Normal file
View File

@@ -0,0 +1,321 @@
package main
import (
"context"
"fmt"
"io"
"os"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip22"
"github.com/fatih/color"
)
func fetchThreadComments(ctx context.Context, relays []string, discussionID nostr.ID, extraTags nostr.TagMap) ([]nostr.RelayEvent, error) {
filterTags := nostr.TagMap{
"E": []string{discussionID.Hex()},
}
for key, values := range extraTags {
filterTags[key] = values
}
comments := make([]nostr.RelayEvent, 0, 15)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{1111},
Tags: filterTags,
Limit: 500,
}, nostr.SubscriptionOptions{Label: "nak-thread"}) {
comments = append(comments, ie)
}
slices.SortFunc(comments, nostr.CompareRelayEvent)
return comments, nil
}
func showThreadWithComments(
ctx context.Context,
relays []string,
evt nostr.RelayEvent,
status string,
extraTags nostr.TagMap,
) error {
comments, err := fetchThreadComments(ctx, relays, evt.ID, extraTags)
if err != nil {
return err
}
printThreadMetadata(ctx, os.Stdout, evt, status, true)
stdout("")
stdout(evt.Content)
if len(comments) > 0 {
stdout("")
stdout(color.CyanString("comments:"))
printThreadedComments(ctx, os.Stdout, comments, evt.ID, true)
}
return nil
}
func printThreadedComments(
ctx context.Context,
w io.Writer,
comments []nostr.RelayEvent,
discussionID nostr.ID,
withColor bool,
) {
byID := make(map[nostr.ID]struct{}, len(comments)+1)
byID[discussionID] = struct{}{}
for _, c := range comments {
byID[c.ID] = struct{}{}
}
// preload metadata from everybody
wg := sync.WaitGroup{}
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
for _, c := range comments {
wg.Go(func() {
sys.FetchProfileMetadata(ctx, c.PubKey)
})
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
if !ok {
continue
}
if _, ok := byID[parent.ID]; ok {
children[parent.ID] = append(children[parent.ID], c)
}
}
for parent := range children {
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
}
wg.Wait()
var render func(parent nostr.ID, depth int)
render = func(parent nostr.ID, depth int) {
for _, c := range children[parent] {
indent := strings.Repeat(" ", depth)
author := authorPreview(ctx, c.PubKey)
created := c.CreatedAt.Time().Format(time.DateTime)
if withColor {
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
} else {
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
}
for _, line := range strings.Split(c.Content, "\n") {
fmt.Fprintln(w, indent+" "+line)
}
fmt.Fprintln(w, indent+"")
render(c.ID, depth+1)
}
}
render(discussionID, 0)
}
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix == "" {
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
}
matchCount := 0
matched := nostr.RelayEvent{}
for _, evt := range events {
if strings.HasPrefix(evt.ID.Hex(), prefix) {
matched = evt
matchCount++
}
}
if matchCount == 0 {
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
}
if matchCount > 1 {
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
}
return matched, nil
}
func printThreadMetadata(
ctx context.Context,
w io.Writer,
evt nostr.RelayEvent,
status string,
withColors bool,
) {
label := func(s string) string { return s }
value := func(s string) string { return s }
statusValue := func(s string) string { return s }
if withColors {
label = func(s string) string { return color.CyanString(s) }
value = func(s string) string { return color.HiWhiteString(s) }
statusValue = colorizeGitStatus
}
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
if status != "" {
fmt.Fprintln(w, label("status:"), statusValue(status))
}
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
fmt.Fprintln(w, label("subject:"), value(subject[1]))
fmt.Fprintln(w, "")
} else if title := evt.Tags.Find("title"); title != nil && len(title) >= 2 {
fmt.Fprintln(w, label("title:"), value(title[1]))
fmt.Fprintln(w, "")
}
}
func parseThreadReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
currentParent := discussion
selectedParent := nostr.ZeroID
inComments := false
replyb := strings.Builder{}
for _, line := range strings.Split(edited, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
inComments = false
currentParent = discussion
continue
}
if replyb.Len() == 0 && line == "" {
continue
}
if strings.HasPrefix(line, "#>") {
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
if quoted == "comments:" {
inComments = true
currentParent = discussion
continue
}
// keep track of which comment the reply body shows up below of
// so we can assign it as a reply to that specifically
fields := strings.Fields(quoted)
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
currId := fields[0][1 : len(fields[0])-1]
for _, comment := range comments {
if strings.HasPrefix(comment.ID.Hex(), currId) {
currentParent = comment
break
}
}
}
continue
}
// if we reach here this is a line for the reply input from the user
replyb.WriteString(line)
replyb.WriteByte('\n')
if line == "" {
continue
}
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
}
selectedParent = currentParent.ID
}
content := strings.TrimSpace(replyb.String())
if content == "" {
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
}
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
return content, discussion, nil
}
for _, comment := range comments {
if comment.ID == selectedParent {
return content, comment, nil
}
}
panic("selected reply parent not found (this never happens)")
}
func threadReplyEditorTemplate(ctx context.Context, headerLines []string, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
lines := make([]string, 0, len(headerLines)+3)
for _, line := range headerLines {
if strings.TrimSpace(line) == "" {
continue
}
lines = append(lines, "# "+strings.TrimSpace(line))
}
lines = append(lines,
"# write your reply here.",
"# lines starting with '#' are ignored.",
"",
)
appender := &lineAppender{lines, "#> "}
printThreadMetadata(ctx, appender, discussion, "", false)
for _, line := range strings.Split(discussion.Content, "\n") {
appender.lines = append(appender.lines, "#> "+line)
}
if len(comments) > 0 {
appender.lines = append(appender.lines, "#> ", "#> comments:")
printThreadedComments(ctx, appender, comments, discussion.ID, false)
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
}
return strings.Join(appender.lines, "\n")
}
func keyerIdentity(ctx context.Context, kr nostr.Keyer) (nostr.PubKey, string, string, error) {
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return nostr.ZeroPK, "", "", err
}
meta := sys.FetchProfileMetadata(ctx, pk)
return pk, meta.ShortName(), meta.NpubShort(), nil
}
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
meta := sys.FetchProfileMetadata(ctx, pubkey)
if meta.Name != "" {
return meta.ShortName() + " (" + meta.NpubShort() + ")"
}
return meta.NpubShort()
}
type lineAppender struct {
lines []string
prefix string
}
func (l *lineAppender) Write(b []byte) (int, error) {
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
line = strings.TrimRight(line, " ")
l.lines = append(l.lines, l.prefix+line)
}
return len(b), nil
}

81
validate.go Normal file
View File

@@ -0,0 +1,81 @@
package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
var validateCmd = &cli.Command{
Name: "validate",
Usage: "validates events against the provided RoK YAML schema",
Description: `takes a URL to a YAML schema in the same format as that of https://github.com/nostr-protocol/registry-of-kinds (defaults to that one) and validates the event tags and content against it, according to its kind.
example:
nak event -k 1 -p not_a_pubkey | nak validate
>> schema validation failed: tag[0][1]: invalid pubkey value 'not_a_pubkey' at tag 'p', index 1: pubkey should be 64-char hex, got 'not_a_pubkey'
`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml",
TakesFile: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
var validator schema.Validator
if schemaURL := c.String("schema"); strings.HasPrefix(schemaURL, "http") {
var err error
validator, err = schema.NewValidatorFromURL(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from '%s': %w", schemaURL, err)
}
} else {
var err error
validator, err = schema.NewValidatorFromFile(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from %s: %w", schemaURL, err)
}
}
handleEvent := func(stdinEvent string) error {
evt := nostr.Event{}
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
return fmt.Errorf("invalid event JSON: %w", err)
}
if err := validator.ValidateEvent(evt); err != nil {
return fmt.Errorf("schema validation failed: %w", err)
}
stdout(evt)
return nil
}
for stdinEvent := range getJsonsOrBlank() {
if stdinEvent == "" {
for _, arg := range c.Args().Slice() {
if err := handleEvent(arg); err != nil {
ctx = lineProcessingError(ctx, "%s", err)
}
}
continue
}
if err := handleEvent(stdinEvent); err != nil {
ctx = lineProcessingError(ctx, "%s", err)
}
}
exitIfLineProcessingError(ctx)
return nil
},
}

View File

@@ -4,36 +4,59 @@ import (
"context"
"fiatjaf.com/nostr"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var verify = &cli.Command{
Name: "verify",
Usage: "checks the hash and signature of an event given through stdin",
Usage: "checks the hash and signature of an event given through stdin or as the first argument",
Description: `example:
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak verify
it outputs nothing if the verification is successful.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for stdinEvent := range getStdinLinesOrArguments(c.Args()) {
for stdinEvent := range getJsonsOrBlank() {
evt := nostr.Event{}
if stdinEvent != "" {
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
ctx = lineProcessingError(ctx, "invalid event: %s", err)
if stdinEvent == "" {
stdinEvent = c.Args().First()
if stdinEvent == "" {
continue
}
}
if evt.GetID() != evt.ID {
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
ctx = lineProcessingError(ctx, "invalid event: %s", err)
logverbose("%s\n", color.RedString("<>: invalid event."))
continue
}
impliedID := evt.GetID()
idsMatch := impliedID == evt.ID
logverbose(
"%s\n%s %s\n%s %s\n%s %s\n%s %s\n%s %s\n",
color.CyanString("verifying event:"),
color.BlueString(" event: "), stdinEvent,
color.BlueString(" given id: "), color.YellowString("%s", evt.ID),
color.BlueString(" serialized:"), string(evt.Serialize()),
color.BlueString(" implied id:"), color.YellowString("%s", impliedID),
color.BlueString(" ids match: "), color.New(map[bool]color.Attribute{true: color.FgGreen, false: color.FgRed}[idsMatch]).Sprint(idsMatch),
)
if impliedID != evt.ID {
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", impliedID, evt.ID)
logverbose("%s\n", color.RedString("invalid id: %s", evt.ID.Hex()))
continue
}
if !evt.VerifySignature() {
ctx = lineProcessingError(ctx, "invalid signature")
logverbose("%s\n", color.RedString("invalid signature: %s", evt.ID.Hex()))
continue
}
logverbose("%s\n", color.GreenString("valid: %s", evt.ID.Hex()))
}
exitIfLineProcessingError(ctx)

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
@@ -27,12 +28,30 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
relays := sys.FetchOutboxRelays(ctx, pk, 3)
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
if w == nil {
return nil, nil, fmt.Errorf("error loading walle")
return nil, nil, fmt.Errorf("error loading wallet")
}
w.Processed = func(evt nostr.Event, err error) {
if err == nil {
logverbose("processed event %s\n", evt)
if c.Bool("stream") {
// after EOSE log updates and the new balance
select {
case <-w.Stable:
switch evt.Kind {
case 5:
log("- token deleted\n")
case 7375:
log("- token added\n")
default:
return
}
log(" balance: %d\n", w.Balance())
default:
}
}
} else {
log("error processing event %s: %s\n", evt, err)
}
@@ -86,15 +105,25 @@ var wallet = &cli.Command{
Usage: "displays the current wallet balance",
Description: "all wallet data is stored on Nostr relays, signed and encrypted with the given key, and reloaded again from relays on every call.\n\nthe same data can be accessed by other compatible nip60 clients.",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Flags: append(defaultKeyFlags,
&cli.BoolFlag{
Name: "stream",
Usage: "keep listening for wallet-related events and logging them",
},
),
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
log("balance: ")
stdout(w.Balance())
if c.Bool("stream") {
<-ctx.Done() // this will hang forever
}
closew()
return nil
},
@@ -110,7 +139,11 @@ var wallet = &cli.Command{
}
for _, url := range w.Mints {
stdout(strings.Split(url, "://")[1])
if _, host, ok := strings.Cut(url, "://"); ok {
stdout(host)
} else {
stdout(url)
}
}
closew()
@@ -166,12 +199,49 @@ var wallet = &cli.Command{
}
for _, token := range w.Tokens {
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
stdout(token.ID(), token.Proofs.Amount(), mintHost)
}
closew()
return nil
},
Commands: []*cli.Command{
{
Name: "drop",
Usage: "deletes a token from the wallet",
DisableSliceFlagSeparator: true,
ArgsUsage: "<id>...",
Action: func(ctx context.Context, c *cli.Command) error {
ids := c.Args().Slice()
if len(ids) == 0 {
return fmt.Errorf("no token ids specified")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
for _, token := range w.Tokens {
if slices.Contains(ids, token.ID()) {
w.DropToken(ctx, token.ID())
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), mintHost)
}
}
closew()
return nil
},
},
},
},
{
Name: "receive",
@@ -300,6 +370,10 @@ var wallet = &cli.Command{
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "target",
Usage: "npub, nprofile, nevent or hex pubkey",
},
&cli.StringFlag{
Name: "mint",
Usage: "send from a specific mint",
@@ -310,9 +384,8 @@ var wallet = &cli.Command{
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) < 2 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
if c.Args().Len() < 1 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> --target <target>...")
}
w, closew, err := prepareWallet(ctx, c)