Compare commits

...

225 Commits

Author SHA1 Message Date
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
fiatjaf
6e5441aa18 fix type assertions.
closes https://github.com/fiatjaf/nak/issues/67
2025-05-22 09:23:26 -03:00
fiatjaf
61a68f3dca fix faulty outbox database check logic when it doesn't exist.
fixes https://github.com/fiatjaf/nak/issues/66
2025-05-21 14:12:22 -03:00
fiatjaf
f450e735b6 sticky version. 2025-05-20 23:45:54 -03:00
franzaps
1304a65179 Update zapstore.yaml 2025-05-20 23:36:26 -03:00
fiatjaf
aa89093d57 accept bunker URIs in $NOSTR_SECRET_KEY, simplify.
fixes https://github.com/fiatjaf/nak/issues/66
2025-05-20 23:32:04 -03:00
fiatjaf
4387595437 serve: display number of connections. 2025-05-17 21:42:45 -03:00
fiatjaf
4eb5e929d4 blossom: upload from stdin. 2025-05-14 23:43:11 -03:00
fiatjaf
150625ee74 remove debug.PrintStack() 2025-05-12 09:20:27 -03:00
fiatjaf
fc255b5a9a optimized clamped error message for status code failures. 2025-05-11 12:15:50 -03:00
fiatjaf
5bcf2da794 adapt serve to variable max eventstores. 2025-05-11 12:09:56 -03:00
fiatjaf
aadcc73906 adapt to since and until not being pointers. 2025-05-08 09:59:03 -03:00
fiatjaf
f799c65779 add nak publish to README. 2025-05-07 07:59:43 -03:00
fiatjaf
c3822225b4 small tweaks to readme examples. 2025-05-06 11:50:29 -03:00
fiatjaf
67e291e80d nak publish 2025-05-06 00:56:49 -03:00
fiatjaf
83195d9a00 fs: use sdk/PrepareNoteEvent() when publishing. 2025-05-06 00:05:50 -03:00
fiatjaf
f9033f778d adapt wallet to upstream changes. 2025-05-05 16:57:05 -03:00
fiatjaf
9055f98f66 use color.Output and color.Error instead of os.Stdout and os.Stderr in some places. 2025-05-03 21:45:28 -03:00
fiatjaf
02f22a8c2f nak event --confirm 2025-05-03 21:44:59 -03:00
Alex Gleason
f98bd7483f allow --prompt-sec to be used with pipes 2025-05-03 11:58:17 -03:00
fiatjaf
3005c62566 blossom method name update. 2025-05-03 07:22:08 -03:00
fiatjaf
e91a454fc0 nak encode that takes json from stdin. 2025-04-25 13:30:32 -03:00
fiatjaf
148f6e8bcb remove cruft and comments from flags.go 2025-04-25 12:45:38 -03:00
fiatjaf
024111a8be fix bunker client key variable. 2025-04-24 13:22:44 -03:00
fiatjaf
8fba611ad0 mcp: make search return multiple users and also their name and description. 2025-04-22 15:32:48 -03:00
fiatjaf
4d12550d74 bunker: cosmetic fixes. 2025-04-22 08:38:00 -03:00
fiatjaf
5d44600f17 test and fixes. 2025-04-21 18:09:27 -03:00
fiatjaf
5a8c7df811 fix and simplify nak decode. 2025-04-21 15:37:13 -03:00
fiatjaf
01be954ae6 use badger for outbox hints. 2025-04-21 15:33:49 -03:00
fiatjaf
d733a31898 convert to using nostrlib. 2025-04-20 18:11:21 -03:00
fiatjaf
1b43dbda02 remove dvm command. 2025-04-19 18:07:01 -03:00
fiatjaf
e45b54ea62 fix nak mcp. 2025-04-10 16:59:56 -03:00
fiatjaf
35da063c30 precheck for validity of relay URLs and prevent unwanted crash otherwise. 2025-04-07 23:13:32 -03:00
fiatjaf
15aefe3df4 more examples on readme. 2025-04-03 22:17:30 -03:00
fiatjaf
55fd631787 fix term.GetSize() when piping. 2025-04-03 22:08:11 -03:00
fiatjaf
6f48c29d0f fix go-nostr dependency. 2025-04-03 21:31:12 -03:00
fiatjaf
703c186958 much more colors everywhere and everything is prettier. 2025-04-03 14:50:25 -03:00
fiatjaf
7ae2e686cb more colors. 2025-04-03 11:57:18 -03:00
fiatjaf
9547711e8d nice dynamic UI when connecting to relays, and go much faster concurrently. 2025-04-03 11:42:33 -03:00
fiatjaf
50119e21e6 fix nip73.ExternalPointer reference 2025-04-02 22:38:12 -03:00
fiatjaf
33f4272dd0 update go-nostr to maybe fix nip-60 wallets? 2025-04-02 22:37:59 -03:00
fiatjaf
7b6f387aad tags GetFirst() => Find() 2025-03-29 17:12:31 -03:00
fiatjaf
b1a03800e6 add fake fs command that doesn't work when compiling for windows but at least compiles. 2025-03-19 15:12:39 -03:00
fiatjaf
db5dafb58a fix arm64 builds by removing base64x dependencies. 2025-03-19 15:05:27 -03:00
fiatjaf
4b15cdf625 fs: publishing new notes by writing to ./notes/new 2025-03-15 00:34:13 -03:00
fiatjaf
4b8c067e00 fs: creating articles (and presumably wikis); fixes and improvements to editing articles. 2025-03-13 01:13:34 -03:00
fiatjaf
931da4b0ae fs: editable articles and wiki. 2025-03-12 08:03:10 -03:00
fiatjaf
c87371208e fs: pass NostrRoot everywhere with a signer only if it can actually sign. 2025-03-11 13:18:33 -03:00
fiatjaf
bfe1e6ca94 fs: rename pictures -> photos. 2025-03-11 12:38:35 -03:00
fiatjaf
602e03a9a1 fs: do not paginate videos and highlights (should make this dynamic in the future). 2025-03-11 12:38:32 -03:00
fiatjaf
fe1f50f798 fs: logging and proper (?) handling of context passing (basically now we ignore the context given to us by the fuse library because they're weird). 2025-03-11 12:37:27 -03:00
fiatjaf
d899a92f15 trying to get this thing to build again on github. 2025-03-10 17:36:44 -03:00
fiatjaf
1c058f2846 fix github builds by removing some odd platform combinations. 2025-03-10 17:18:14 -03:00
fiatjaf
4b4d9ec155 fs: some fixes and profile pictures. 2025-03-10 17:07:02 -03:00
fiatjaf
3031568266 fs: articles and wikis. 2025-03-10 16:02:52 -03:00
fiatjaf
a828ee3793 fs: a much more complete directory hierarchy and everything mostly working in read-only mode. 2025-03-10 14:38:19 -03:00
fiatjaf
186948db9a fs: deterministic inode numbers. 2025-03-09 00:17:56 -03:00
fiatjaf
5fe354f642 fs: symlink from @me to ourselves. 2025-03-09 00:13:30 -03:00
fiatjaf
3d961d4bec fs: something that makes more sense. 2025-03-08 12:52:05 -03:00
fiatjaf
d6a23bd00c experimental nak fs 2025-03-08 10:51:44 -03:00
fiatjaf
c1248eb37b update dependencies, includes authenticated blossom blob uploads. 2025-03-07 10:23:52 -03:00
fiatjaf
c60bb82be8 small fixes on dvm flags and stuff. 2025-03-06 08:06:27 -03:00
fiatjaf
f5316a0f35 preliminary (broken) dvm support. 2025-03-05 22:01:24 -03:00
fiatjaf
e6448debf2 blossom command moved into here. 2025-03-05 00:37:42 -03:00
fiatjaf
7bb7543ef7 serve: setup relay info. 2025-03-03 16:29:20 -03:00
fiatjaf
43a3e5f40d event: support reading --content from a file if the name starts with @. 2025-02-18 18:19:36 -03:00
fiatjaf
707e5b3918 req: print at least something when auth fails. 2025-02-17 17:01:29 -03:00
fiatjaf
faca2e50f0 adapt to FetchSpecificEvent() changes. 2025-02-17 16:54:31 -03:00
fiatjaf
26930d40bc migrate to urfave/cli/v3 again now that they have flags after arguments. 2025-02-16 13:02:04 -03:00
fiatjaf
17920d8aef adapt to go-nostr's new methods that take just one filter (and paginator). 2025-02-13 23:10:18 -03:00
fiatjaf
95bed5d5a8 nak req --ids-only 2025-02-12 16:37:17 -03:00
fiatjaf
2e30dfe2eb wallet: fix nutzap error message. 2025-02-12 15:51:00 -03:00
fiatjaf
55c6f75b8a mcp: fix a bunch of stupid bugs. 2025-02-07 16:54:23 -03:00
fiatjaf
1f2492c9b1 fix multiline handler thing for when we're don't have any stdin. 2025-02-05 20:42:55 -03:00
fiatjaf
d00976a669 curl: assume POST when there is data and no method is specified. 2025-02-05 10:39:30 -03:00
fiatjaf
4392293ed6 curl method and negative make fixes. 2025-02-05 10:22:04 -03:00
fiatjaf
60d1292f80 parse multiline json from input on nak event and nak req, use iterators instead of channels for more efficient stdin parsing. 2025-02-05 09:44:16 -03:00
fiatjaf
6c634d8081 nak curl 2025-02-04 23:20:35 -03:00
fiatjaf
1e353680bc wallet: adapt to single-wallet nip60 mode and implement nutzapping. 2025-02-04 22:38:40 -03:00
fiatjaf
ff8701a3b0 fetch: stop adding kind:0 to all requests when they already have a kind specified. 2025-02-03 15:54:12 -03:00
fiatjaf
ad6b8c4ba5 more mcp stuff. 2025-02-02 00:21:25 -03:00
fiatjaf
dba3f648ad experimental mcp server. 2025-01-31 23:44:03 -03:00
fiatjaf
12a1f1563e wallet pay and fix missing tokens because the program was exiting before they were saved. 2025-01-30 19:59:54 -03:00
fiatjaf
e2dd3ca544 fix -v verbose flag (it was being used by the default --version flag). 2025-01-30 19:58:17 -03:00
fiatjaf
df5ebd3f56 global verbose flag. 2025-01-30 16:06:16 -03:00
fiatjaf
81571c6952 global bold/italic helpers. 2025-01-30 16:05:51 -03:00
fiatjaf
6e43a6b733 reword NIP-XX to nipXX everywhere. 2025-01-29 19:13:30 -03:00
fiatjaf
943e8835f9 nak wallet 2025-01-28 23:50:09 -03:00
fiatjaf
6b659c1552 fix @mleku unnecessary newlines. 2025-01-28 23:50:09 -03:00
mleku
aa53f2cd60 show env var in help, reset terminal mode correctly 2025-01-21 16:14:59 -03:00
fiatjaf
5509095277 nak outbox 2025-01-18 18:40:19 -03:00
fiatjaf
a3ef9b45de update go-nostr to solve some websocket things like a stupid limit to the websocket message. 2025-01-18 15:45:08 -03:00
fiatjaf
df20a3241a update go-nostr to fix url path normalization (@pablof7z's @primalhq NWC bug). 2025-01-16 21:46:43 -03:00
fiatjaf
53a2451303 update go-nostr and adjust user-agent stuff.
this changes the websocket library we were using.
2025-01-16 16:02:08 -03:00
fiatjaf
2d992f235e bunker: fix MarshalIndent() is not allowed to have a prefix on json-iterator. 2024-12-13 23:27:08 -03:00
franzap
7675929056 Add zapstore.yaml 2024-12-11 19:02:56 -03:00
fiatjaf
7f608588a2 improve count and support hyperloglog aggregation. 2024-12-10 23:28:36 -03:00
fiatjaf
fd5cd55f6f replace encoding/json with json-iterator everywhere so we get rid of HTML encoding and maybe be faster. 2024-12-03 00:43:52 -03:00
redraw
932361fe8f fix(decode): handle event id flag 2024-12-02 10:25:38 -03:00
redraw
11ae7bc4d3 add test ExampleDecodePubkey 2024-12-02 09:11:19 -03:00
redraw
7033bfee19 fix(decode): handle pubkey flag 2024-12-02 09:11:19 -03:00
fiatjaf
f425097c5a allow filters with long tags (the 1-char restriction is only a convention, not a rule).
fixes https://github.com/fiatjaf/nak/issues/44
2024-11-26 12:05:40 -03:00
fiatjaf
dd0ef2ca64 relay management examples on help. 2024-11-25 12:50:47 -03:00
Yasuhiro Matsumoto
491a094e07 close ch 2024-11-23 08:14:13 -03:00
fiatjaf
9d619ddf00 remove note1 encoding. 2024-11-19 07:47:11 -03:00
fiatjaf
5d32739573 update go-nostr again, apparently this was necessary. 2024-11-12 18:46:38 -03:00
fiatjaf
a187e448f2 get rid of some of the HTML escaping that plagues golang json. 2024-11-11 23:09:15 -03:00
fiatjaf
9a9e96a829 support $NOSTR_CLIENT_KEY environment variable for --connect-as 2024-11-11 22:33:17 -03:00
fiatjaf
4c6181d649 update go-nostr so all bunkers are nip44 maximalists. 2024-11-01 08:44:15 -03:00
fiatjaf
71b106fd45 update go-nostr so we always encrypt nip46 messages with nip44. 2024-10-30 10:39:09 -03:00
Yasuhiro Matsumoto
40892c1228 build arm binary for Raspberry Pi 32bit 2024-10-30 10:33:31 -03:00
fiatjaf
847f8aaa69 remove duplicated password decryption prompts by returning the bare key together with the Keyer when it is given. 2024-10-29 21:11:15 -03:00
fiatjaf
134d1225d6 nak event: presence of key flags indicates the need to resign a given event.
fixes https://github.com/fiatjaf/nak/issues/41
2024-10-29 13:33:35 -03:00
fiatjaf
464766a836 allow "=" in tag value.
fixes https://github.com/fiatjaf/nak/issues/40
2024-10-27 11:01:30 -03:00
fiatjaf
ea53eca74f update go-nostr for nip44-on-nip46 fixes. 2024-10-27 09:56:49 -03:00
fiatjaf
38ed370c59 slightly improve verify error message. 2024-10-11 17:59:08 -03:00
fiatjaf
5b04bc4859 nak key public --with-parity 2024-10-08 09:08:50 -03:00
fiatjaf
2988c71ccb nak/b and nak/s user-agents. 2024-09-26 22:17:31 -03:00
fiatjaf
d7c0ff2bb7 update go-nostr keyer interface and make req --auth work again. 2024-09-22 19:21:41 -03:00
fiatjaf
43fe41df5d use log() function instead of fmt.Fprintf(os.Stderr) in some places. 2024-09-22 19:04:21 -03:00
fiatjaf
3215726417 use stdout() function instead of fmt.Println() in some places. 2024-09-21 12:02:09 -03:00
fiatjaf
a4886dc445 nak encrypt and nak decrypt: nip44 with option to do nip04.
closes https://github.com/fiatjaf/nak/issues/36
2024-09-17 11:33:02 -03:00
fiatjaf
dae7eba8ca use keyer.Keyer in most places instead of raw bunkers and plaintext keys, simplifies the code a little at the cost of some abstraction but I think it's strictly good this time. 2024-09-17 11:33:02 -03:00
fiatjaf
2b5f3355bc use a single global sdk.System and its Pool. 2024-09-17 11:33:02 -03:00
fiatjaf
bd5ca27661 github.ref->github.ref_name as version variable. 2024-09-15 09:04:52 -03:00
fiatjaf
9d02301b2d support --version using -X 2024-09-15 08:57:53 -03:00
arkinox
9bbc87b27a specify how ; can separate multiple tag values 2024-09-10 19:13:25 -03:00
fiatjaf
88a07a3504 update go-nostr and nostr-sdk to fix bad nevent/naddr parsing bug. 2024-09-05 14:43:34 -03:00
fiatjaf
8a934cc76b fix fetch naddr missing kind. 2024-08-28 16:13:19 -03:00
fiatjaf
e0c967efa9 fix natural timestamps test. 2024-08-26 15:59:48 -03:00
fiatjaf
36c32ae308 make it possible to have empty content on kind 1.
fixes https://github.com/fiatjaf/nak/issues/32
2024-08-26 15:49:13 -03:00
fiatjaf
6d23509d8c fetch: handle note1 case. 2024-08-25 17:10:06 -03:00
fiatjaf
29b6ecbafe readme: how to download torrents. 2024-08-25 08:35:25 -03:00
fiatjaf
11f37afa5b readme: how to watch livestreams from your terminal. 2024-08-24 21:40:08 -03:00
fiatjaf
cf1694704e bunker: fix printing bunker uri. 2024-08-23 16:17:17 -03:00
fiatjaf
b3ef2c1289 update go-nostr because parallel work generation was broken. 2024-08-21 17:09:15 -03:00
fiatjaf
cfdea699bc fix using NOSTR_SECRET_KEY environment variable. 2024-08-21 10:46:29 -03:00
fiatjaf
014c6bc11d --pow: parallel work. 2024-08-20 23:06:14 -03:00
fiatjaf
0240866fa1 fix fetch for non-pubkey cases. 2024-08-20 18:39:17 -03:00
54 changed files with 9392 additions and 1390 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

@@ -33,6 +33,8 @@ jobs:
goos: windows
- goarch: riscv64
goos: darwin
- goarch: arm64
goos: freebsd
steps:
- uses: actions/checkout@v3
- uses: wangyoucao577/go-release-action@v1.40
@@ -40,6 +42,7 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
ldflags: -X main.version=${{ github.ref_name }}
overwrite: true
md5sum: false
sha256sum: false

View File

@@ -0,0 +1,97 @@
name: Smoke test the binary
on:
workflow_run:
workflows: ["build cli for all platforms"]
types:
- completed
branches:
- master
jobs:
smoke-test-linux-amd64:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
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 "Running basic tests..."
./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: $PUBLIC_KEY"
# Create events
echo "Testing event creation..."
./nak event -c "hello world"
./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
NOTE_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
NOTE1=$(./nak encode note $NOTE_ID)
echo "Encoded note1: $NOTE1"
./nak decode $NOTE1
./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 relay operations..."
# Publish a simple event to a public relay
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "Test from nak smoke test" nos.lol)
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"

2
.gitignore vendored
View File

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

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# build stage
FROM golang:1.24-alpine AS builder
# install git and ca-certificates (needed for fetching dependencies)
RUN apk add --no-cache git ca-certificates
# 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=0 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"]

252
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)
}
}

250
blossom.go Normal file
View File

@@ -0,0 +1,250 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/urfave/cli/v3"
)
var blossomCmd = &cli.Command{
Name: "blossom",
Suggest: true,
UseShortOptionHandling: true,
Usage: "an army knife for blossom things",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "the hostname of the target mediaserver",
Required: true,
},
),
Commands: []*cli.Command{
{
Name: "list",
Usage: "lists blobs from a pubkey",
Description: `takes one pubkey passed as an argument or derives one from the --sec supplied. if that is given then it will also pre-authorize the list, which some servers may require.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "[pubkey]",
Action: func(ctx context.Context, c *cli.Command) error {
var client *blossom.Client
pubkey := c.Args().First()
if pubkey != "" {
pk, err := parsePubKey(pubkey)
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
}
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
} else {
var err error
client, err = getBlossomClient(ctx, c)
if err != nil {
return err
}
}
bds, err := client.List(ctx)
if err != nil {
return err
}
for _, bd := range bds {
stdout(bd)
}
return nil
},
},
{
Name: "upload",
Usage: "uploads a file to a specific mediaserver.",
Description: `takes any number of local file paths and uploads them to a mediaserver, printing the resulting blob descriptions when successful.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "[files...]",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
if isPiped() {
// get file from stdin
if c.Args().Len() > 0 {
return fmt.Errorf("do not pass arguments when piping from stdin")
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read stdin: %w", err)
}
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
if err != nil {
return err
}
j, _ := json.Marshal(bd)
stdout(string(j))
} else {
// get filenames from arguments
hasError := false
for _, fpath := range c.Args().Slice() {
bd, err := client.UploadFilePath(ctx, fpath)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasError = true
continue
}
j, _ := json.Marshal(bd)
stdout(string(j))
}
if hasError {
os.Exit(3)
}
}
return nil
},
},
{
Name: "download",
Usage: "downloads files from mediaservers",
Description: `takes any number of sha256 hashes as hex, downloads them and prints them to stdout (unless --output is specified).`,
DisableSliceFlagSeparator: true,
ArgsUsage: "[sha256...]",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "file name to save downloaded file to, can be passed multiple times when downloading multiple hashes",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
outputs := c.StringSlice("output")
hasError := false
for i, hash := range c.Args().Slice() {
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)
hasError = true
}
} else {
// if output wasn't specified, print to stdout
data, err := client.Download(ctx, hash)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasError = true
continue
}
stdout(data)
}
}
if hasError {
os.Exit(2)
}
return nil
},
},
{
Name: "del",
Aliases: []string{"delete"},
Usage: "deletes a file from a mediaserver",
Description: `takes any number of sha256 hashes, signs authorizations and deletes them from the current mediaserver.
if any of the files are not deleted command will fail, otherwise it will succeed. it will also print error messages to stderr and the hashes it successfully deletes to stdout.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "[sha256...]",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
hasError := false
for _, hash := range c.Args().Slice() {
err := client.Delete(ctx, hash)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasError = true
continue
}
stdout(hash)
}
if hasError {
os.Exit(3)
}
return nil
},
},
{
Name: "check",
Usage: "asks the mediaserver if it has the specified hashes.",
Description: `uses the HEAD request to succintly check if the server has the specified sha256 hash.
if any of the files are not found the command will fail, otherwise it will succeed. it will also print error messages to stderr and the hashes it finds to stdout.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "[sha256...]",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
hasError := false
for _, hash := range c.Args().Slice() {
err := client.Check(ctx, hash)
if err != nil {
hasError = true
fmt.Fprintf(os.Stderr, "%s\n", err)
continue
}
stdout(hash)
}
if hasError {
os.Exit(2)
}
return nil
},
},
{
Name: "mirror",
Usage: "",
Description: ``,
DisableSliceFlagSeparator: true,
ArgsUsage: "",
Action: func(ctx context.Context, c *cli.Command) error {
return nil
},
},
},
}
func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, error) {
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return nil, err
}
return blossom.NewClient(c.String("server"), keyer), nil
}

400
bunker.go
View File

@@ -1,35 +1,51 @@
package main
import (
"bytes"
"context"
"encoding/json"
"encoding/hex"
"fmt"
"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/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46"
"golang.org/x/exp/slices"
"github.com/mdp/qrterminal/v3"
"github.com/urfave/cli/v3"
)
const PERSISTENCE = "PERSISTENCE"
var bunker = &cli.Command{
Name: "bunker",
Usage: "starts a NIP-46 signer daemon with the given --sec key",
Usage: "starts a nip46 signer daemon with the given --sec key",
ArgsUsage: "[relay...]",
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",
DefaultText: "the key '1'",
Value: "0000000000000000000000000000000000000000000000000000000000000001",
},
&cli.BoolFlag{
Name: "prompt-sec",
@@ -40,39 +56,170 @@ var bunker = &cli.Command{
Aliases: []string{"s"},
Usage: "secrets for which we will always respond",
},
&cli.StringSliceFlag{
&PubKeySliceFlag{
Name: "authorized-keys",
Aliases: []string{"k"},
Usage: "pubkeys for which we will always respond",
},
&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 {
// read config from file
config := struct {
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
}{
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
}
baseRelaysUrls := 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)
}
}
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
}
}
}
// 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: %w\n"), err)
os.Exit(4)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
log(color.RedString("failed to persist: %w\n"), err)
os.Exit(4)
}
if err := os.WriteFile(path, data, 0600); err != nil {
log(color.RedString("failed to persist: %w\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
}
} else if !os.IsNotExist(err) {
return err
}
for i, url := range config.Relays {
config.Relays[i] = nostr.NormalizeURL(url)
}
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
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
config.AuthorizedKeys = baseAuthorizedKeys
}
// 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{}
relayURLs := make([]string, 0, c.Args().Len())
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
_, relays := connectToAllRelays(ctx, relayUrls, false)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
for _, relay := range relays {
relayURLs = append(relayURLs, relay.URL)
qs.Add("relay", relay.URL)
}
relayURLs := make([]string, 0, len(config.Relays))
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
for _, relay := range relays {
relayURLs = append(relayURLs, relay.URL)
qs.Add("relay", relay.URL)
}
if len(relayURLs) == 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 := c.StringSlice("authorized-keys")
authorizedSecrets := c.StringSlice("authorized-secrets")
// this will be used to auto-authorize the next person who connects who isn't pre-authorized
@@ -80,32 +227,30 @@ var bunker = &cli.Command{
newSecret := randString(12)
// static information
pubkey, err := nostr.GetPublicKey(sec)
if err != nil {
return err
}
npub, _ := nip19.EncodePublicKey(pubkey)
bold := color.New(color.Bold).Sprintf
italic := color.New(color.Italic).Sprintf
pubkey := sec.Public()
npub := nip19.EncodeNpub(pubkey)
// this function will be called every now and then
printBunkerInfo := func() {
qs.Set("secret", newSecret)
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey, qs.Encode())
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
authorizedKeysStr := ""
if len(authorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:\n - " + italic(strings.Join(authorizedKeys, "\n - "))
if len(config.AuthorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range config.AuthorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
}
}
authorizedSecretsStr := ""
if len(authorizedSecrets) != 0 {
authorizedSecretsStr = "\n authorized secrets:\n - " + italic(strings.Join(authorizedSecrets, "\n - "))
authorizedSecretsStr = "\n authorized secrets:\n - " + colors.italic(strings.Join(authorizedSecrets, "\n - "))
}
preauthorizedFlags := ""
for _, k := range authorizedKeys {
preauthorizedFlags += " -k " + k
for _, k := range config.AuthorizedKeys {
preauthorizedFlags += " -k " + k.Hex()
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
@@ -125,34 +270,52 @@ 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",
bold("%v", relayURLs),
bold(pubkey),
bold(npub),
authorizedKeysStr,
authorizedSecretsStr,
color.CyanString(restartCommand),
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(relayURLs),
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(relayURLs),
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
pool := nostr.NewSimplePool(ctx)
now := nostr.Now()
events := pool.SubMany(ctx, relayURLs, nostr.Filters{
{
Kinds: []int{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey}},
Since: &now,
LimitZero: true,
},
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
signer := nip46.NewStaticKeySigner(sec)
@@ -165,10 +328,10 @@ var bunker = &cli.Command{
cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from string, secret string) bool {
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if secret == newSecret {
// store this key
authorizedKeys = append(authorizedKeys, from)
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
// discard this and generate a new secret
newSecret = randString(12)
// print bunker info again after this
@@ -176,30 +339,34 @@ var bunker = &cli.Command{
time.Sleep(3 * time.Second)
printBunkerInfo()
}()
if persist != nil {
persist()
}
}
return slices.Contains(authorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
}
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(ie.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), string(jreq))
jresp, _ := json.MarshalIndent(resp, " ", " ")
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, _ := pool.EnsureRelay(relayURL); relay != nil {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
@@ -224,7 +391,7 @@ var bunker = &cli.Command{
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
fmt.Fprintf(os.Stderr, "\n")
log("\n")
printBunkerInfo()
}
}()
@@ -232,4 +399,93 @@ var bunker = &cli.Command{
return nil
},
Commands: []*cli.Command{
{
Name: "connect",
Usage: "use the client-initiated NostrConnect flow of NIP46",
ArgsUsage: "<nostrconnect-uri>",
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")
}
// TODO
return fmt.Errorf("this is not implemented yet")
},
},
},
}
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
}

231
cli_test.go Normal file
View File

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

View File

@@ -2,25 +2,26 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"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 NIP-45 request (the flags are mostly the same as 'nak req').`,
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringSliceFlag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
@@ -45,13 +46,13 @@ var count = &cli.Command{
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
@@ -66,18 +67,32 @@ var count = &cli.Command{
},
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{})
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
relayUrls = make([]string, len(relays))
for i, relay := range relays {
relayUrls[i] = relay.URL
if len(relay.URL) > biggerUrlSize {
biggerUrlSize = len(relay.URL)
}
}
}
filter := nostr.Filter{}
if authors := c.StringSlice("author"); len(authors) > 0 {
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = authors
}
if ids := c.StringSlice("id"); len(ids) > 0 {
filter.IDs = ids
}
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
kinds := make([]int, len(kinds64))
kinds := make([]nostr.Kind, len(kinds64))
for i, v := range kinds64 {
kinds[i] = int(v)
kinds[i] = nostr.Kind(v)
}
filter.Kinds = kinds
}
@@ -85,17 +100,17 @@ var count = &cli.Command{
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 && len(spl[0]) == 1 {
tags = append(tags, spl)
if len(spl) == 2 {
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)
@@ -107,38 +122,48 @@ var count = &cli.Command{
}
}
if since := c.Int("since"); since != 0 {
ts := nostr.Timestamp(since)
filter.Since = &ts
if c.IsSet("since") {
filter.Since = getNaturalDate(c, "since")
}
if until := c.Int("until"); until != 0 {
ts := nostr.Timestamp(until)
filter.Until = &ts
if c.IsSet("until") {
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = int(limit)
}
relays := c.Args().Slice()
successes := 0
failures := make([]error, 0, len(relays))
if len(relays) > 0 {
for _, relayUrl := range relays {
relay, err := nostr.RelayConnect(ctx, relayUrl)
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {
failures = append(failures, err)
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
continue
}
count, err := relay.Count(ctx, nostr.Filters{filter})
if err != nil {
failures = append(failures, err)
continue
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " 📋"
}
fmt.Printf("%s: %d\n", relay.URL, count)
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
}
if successes == 0 {
return errors.Join(failures...)
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

132
curl.go Normal file
View File

@@ -0,0 +1,132 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"strings"
"fiatjaf.com/nostr"
"github.com/urfave/cli/v3"
"golang.org/x/exp/slices"
)
var curlFlags []string
var curl = &cli.Command{
Name: "curl",
Usage: "calls curl but with a nip98 header",
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
Flags: defaultKeyFlags,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
// cowboy parsing of curl flags to get the data we need for nip98
var url string
var method string
var presumedMethod string
curlBodyBuildingFlags := []string{
"-d",
"--data",
"--data-binary",
"--data-ascii",
"--data-raw",
"--data-urlencode",
"-F",
"--form",
"--form-string",
"--form-escape",
"--upload-file",
}
nextIsMethod := false
for _, f := range curlFlags {
if nextIsMethod {
method = f
method, _ = strings.CutPrefix(method, `"`)
method, _ = strings.CutSuffix(method, `"`)
method = strings.ToUpper(method)
} else if strings.HasPrefix(f, "https://") || strings.HasPrefix(f, "http://") {
url = f
} else if f == "--request" || f == "-X" {
nextIsMethod = true
continue
} else if slices.Contains(curlBodyBuildingFlags, f) ||
slices.ContainsFunc(curlBodyBuildingFlags, func(s string) bool {
return strings.HasPrefix(f, s)
}) {
presumedMethod = "POST"
}
nextIsMethod = false
}
if url == "" {
return fmt.Errorf("can't create nip98 event: target url is empty")
}
if method == "" {
if presumedMethod != "" {
method = presumedMethod
} else {
method = "GET"
}
}
// make and sign event
evt := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", url},
{"method", method},
},
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return err
}
// the first 2 indexes of curlFlags were reserved for this
curlFlags[0] = "-H"
curlFlags[1] = fmt.Sprintf("Authorization: Nostr %s", base64.StdEncoding.EncodeToString([]byte(evt.String())))
// call curl
cmd := exec.Command("curl", curlFlags...)
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Run()
return nil
},
}
func realCurl() error {
curlFlags = make([]string, 2, max(len(os.Args)-4, 2))
keyFlags := make([]string, 0, 5)
for i := 0; i < len(os.Args[2:]); i++ {
arg := os.Args[i+2]
if slices.ContainsFunc(defaultKeyFlags, func(f cli.Flag) bool {
bareArg, _ := strings.CutPrefix(arg, "-")
bareArg, _ = strings.CutPrefix(bareArg, "-")
return slices.Contains(f.Names(), bareArg)
}) {
keyFlags = append(keyFlags, arg)
if arg != "--prompt-sec" {
i++
val := os.Args[i+2]
keyFlags = append(keyFlags, val)
}
} else {
curlFlags = append(curlFlags, arg)
}
}
return curl.Run(context.Background(), keyFlags)
}

111
decode.go
View File

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

282
dekey.go Normal file
View File

@@ -0,0 +1,282 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip44"
"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-name",
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"
}(),
},
),
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-name")
// 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 {
deviceSec, err = nostr.SecretKeyFromHex(string(data))
if err != nil {
return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err)
}
} else {
// 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)
}
}
devicePub := deviceSec.Public()
// get relays for the user
relays := sys.FetchWriteRelays(ctx, userPub)
relayList := connectToAllRelays(ctx, c, relays, nil, nostr.PoolOptions{})
if len(relayList) == 0 {
return fmt.Errorf("no relays to use")
}
// check if kind:4454 is already published
events := sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4454},
Authors: []nostr.PubKey{userPub},
Tags: nostr.TagMap{
"pubkey": []string{devicePub.Hex()},
},
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
if len(events) == 0 {
// publish kind:4454
evt := nostr.Event{
Kind: 4454,
Content: "",
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"client", deviceName},
{"pubkey", 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
}
}
// check for kind:10044
userKeyEventDate := nostr.Now()
userKeyResult := 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
if userKeyEvent, ok := userKeyResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""}); !ok {
// generate main secret key
eSec = nostr.Generate()
ePub := eSec.Public()
// store it
eKeyPath := filepath.Join(configPath, "dekey", "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 user encryption key: %w", err)
}
// publish kind:10044
evt10044 := nostr.Event{
Kind: 10044,
Content: "",
CreatedAt: userKeyEventDate,
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 {
userKeyEventDate = userKeyEvent.CreatedAt
// get the pub from the tag
for _, tag := range userKeyEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return fmt.Errorf("invalid kind:10044 event, no 'n' tag")
}
// check if we have the key
eKeyPath := filepath.Join(configPath, "dekey", "e", ePub.Hex())
if data, err := os.ReadFile(eKeyPath); err == nil {
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 user encryption key is corrupted: %w", err)
}
} else {
// try to decrypt from kind:4455
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{4455},
Tags: nostr.TagMap{
"p": []string{devicePub.Hex()},
},
}, 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 {
// store it
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
break
}
}
}
}
if eSec == [32]byte{} {
log("main secret key not available, must authorize on another device\n")
return nil
}
// now we have mainSec, check for other kind:4454 events newer than the 10044
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: userKeyEventDate,
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
if keyOrDeviceEvt.Kind == 4455 {
// key event
// skip ourselves
if keyOrDeviceEvt.Tags.FindWithValue("p", devicePub.Hex()) != nil {
continue
}
// assume a key msg will always come before its associated devicemsg
// so just store them here:
pubkeyTag := keyOrDeviceEvt.Tags.Find("p")
if pubkeyTag == nil {
continue
}
keyMsgs = append(keyMsgs, pubkeyTag[1])
} else if keyOrDeviceEvt.Kind == 4454 {
// device event
// skip ourselves
if keyOrDeviceEvt.Tags.FindWithValue("pubkey", devicePub.Hex()) != nil {
continue
}
// if this already has a corresponding keyMsg then skip it
pubkeyTag := keyOrDeviceEvt.Tags.Find("pubkey")
if pubkeyTag == nil {
continue
}
if slices.Contains(keyMsgs, pubkeyTag[1]) {
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
}
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
}
publishFlow(ctx, c, kr, evt4455, relayList)
}
}
return nil
},
}

173
encode.go
View File

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

133
encrypt_decrypt.go Normal file
View File

@@ -0,0 +1,133 @@
package main
import (
"context"
"fmt"
"fiatjaf.com/nostr/nip04"
"github.com/urfave/cli/v3"
)
var encrypt = &cli.Command{
Name: "encrypt",
Usage: "encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64",
ArgsUsage: "[plaintext string]",
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
Required: true,
},
&cli.BoolFlag{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
),
Action: func(ctx context.Context, c *cli.Command) error {
target := getPubKey(c, "recipient-pubkey")
plaintext := c.Args().First()
if c.Bool("nip04") {
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
if bunker != nil {
ciphertext, err := bunker.NIP04Encrypt(ctx, target, plaintext)
if err != nil {
return err
}
stdout(ciphertext)
} else {
ss, err := nip04.ComputeSharedSecret(target, sec)
if err != nil {
return fmt.Errorf("failed to compute nip04 shared secret: %w", err)
}
ciphertext, err := nip04.Encrypt(plaintext, ss)
if err != nil {
return fmt.Errorf("failed to encrypt as nip04: %w", err)
}
stdout(ciphertext)
}
} else {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
res, err := kr.Encrypt(ctx, plaintext, target)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
}
stdout(res)
}
return nil
},
}
var decrypt = &cli.Command{
Name: "decrypt",
Usage: "decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext",
ArgsUsage: "[ciphertext base64]",
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey", "from"},
Required: true,
},
&cli.BoolFlag{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
),
Action: func(ctx context.Context, c *cli.Command) error {
source := getPubKey(c, "sender-pubkey")
ciphertext := c.Args().First()
if c.Bool("nip04") {
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
if bunker != nil {
plaintext, err := bunker.NIP04Decrypt(ctx, source, ciphertext)
if err != nil {
return err
}
stdout(plaintext)
} else {
ss, err := nip04.ComputeSharedSecret(source, sec)
if err != nil {
return fmt.Errorf("failed to compute nip04 shared secret: %w", err)
}
plaintext, err := nip04.Decrypt(ciphertext, ss)
if err != nil {
return fmt.Errorf("failed to decrypt as nip04: %w", err)
}
stdout(plaintext)
}
} else {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
res, err := kr.Decrypt(ctx, ciphertext, source)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
}
stdout(res)
}
return nil
},
}

347
event.go
View File

@@ -2,18 +2,20 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/fiatjaf/cli/v3"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip13"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip13"
"github.com/nbd-wtf/go-nostr/nip19"
"golang.org/x/exp/slices"
"github.com/urfave/cli/v3"
)
const (
@@ -37,30 +39,7 @@ example:
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak event wss://offchain.pub
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex",
DefaultText: "the key '1'",
Value: "0000000000000000000000000000000000000000000000000000000000000001",
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect",
Usage: "sign event using NIP-46, expects a bunker://... URL",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to when communicating with the bunker given on --connect",
DefaultText: "a random key",
Category: CATEGORY_SIGNER,
},
Flags: append(defaultKeyFlags,
// ~ these args are only for the convoluted musig2 signing process
// they will be generally copy-shared-pasted across some manual coordination method between participants
&cli.UintFlag{
@@ -89,7 +68,7 @@ example:
// ~~~
&cli.UintFlag{
Name: "pow",
Usage: "NIP-13 difficulty to target when doing hash work on the event id",
Usage: "nip13 difficulty to target when doing hash work on the event id",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
@@ -99,7 +78,7 @@ example:
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform NIP-42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
@@ -118,7 +97,7 @@ example:
&cli.StringFlag{
Name: "content",
Aliases: []string{"c"},
Usage: "event content",
Usage: "event content (if it starts with an '@' will read from a file)",
DefaultText: "hello from the nostr army knife",
Value: "",
Category: CATEGORY_EVENT_FIELDS,
@@ -126,7 +105,7 @@ example:
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "sets a tag field on the event, takes a value like -t e=<id>",
Usage: "sets a tag field on the event, takes a value like -t e=<id> or -t sometag=\"value one;value two;value three\"",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringSliceFlag{
@@ -152,61 +131,73 @@ example:
Value: nostr.Now(),
Category: CATEGORY_EVENT_FIELDS,
},
},
&cli.BoolFlag{
Name: "confirm",
Usage: "ask before publishing the event",
Category: CATEGORY_EXTRAS,
},
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
// try to connect to the relays here
var relays []*nostr.Relay
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
_, relays = connectToAllRelays(ctx, relayUrls, false)
relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
doAuth := c.Bool("auth")
// then process input and generate events:
// then process input and generate events
for stdinEvent := range getStdinLinesOrBlank() {
evt := nostr.Event{
Tags: make(nostr.Tags, 0, 3),
}
// will reuse this
var evt nostr.Event
kindWasSupplied := false
// this is called when we have a valid json from stdin
handleEvent := func(stdinEvent string) error {
evt.Content = ""
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
contentWasSupplied := strings.Contains(stdinEvent, `"content"`)
mustRehashAndResign := false
if stdinEvent != "" {
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
ctx = lineProcessingError(ctx, "invalid event received from stdin: %s", err)
continue
}
kindWasSupplied = strings.Contains(stdinEvent, `"kind"`)
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
return fmt.Errorf("invalid event received from stdin: %s", err)
}
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
evt.Kind = int(kind)
evt.Kind = nostr.Kind(kind)
mustRehashAndResign = true
} else if !kindWasSupplied {
evt.Kind = 1
mustRehashAndResign = true
}
if content := c.String("content"); content != "" {
evt.Content = content
if c.IsSet("content") {
content := c.String("content")
if strings.HasPrefix(content, "@") {
filedata, err := os.ReadFile(content[1:])
if err != nil {
return fmt.Errorf("failed to read file '%s' for content: %w", content[1:], err)
}
evt.Content = string(filedata)
} else {
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
}
@@ -220,19 +211,31 @@ example:
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
if len(tagValues) >= 1 {
tagValues[0] = decodeTagValue(tagValues[0])
}
tag = append(tag, tagValues...)
}
tags = append(tags, tag)
}
for _, etag := range c.StringSlice("e") {
tags = tags.AppendUnique([]string{"e", etag})
decodedEtag := decodeTagValue(etag)
if tags.FindWithValue("e", decodedEtag) == nil {
tags = append(tags, nostr.Tag{"e", decodedEtag})
}
}
for _, ptag := range c.StringSlice("p") {
tags = tags.AppendUnique([]string{"p", ptag})
decodedPtag := decodeTagValue(ptag)
if tags.FindWithValue("p", decodedPtag) == nil {
tags = append(tags, nostr.Tag{"p", decodedPtag})
}
}
for _, dtag := range c.StringSlice("d") {
tags = tags.AppendUnique([]string{"d", dtag})
decodedDtag := decodeTagValue(dtag)
if tags.FindWithValue("d", decodedDtag) == nil {
tags = append(tags, nostr.Tag{"d", decodedDtag})
}
}
if len(tags) > 0 {
for _, tag := range tags {
@@ -249,14 +252,13 @@ example:
mustRehashAndResign = true
}
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
mustRehashAndResign = true
}
if difficulty := c.Uint("pow"); difficulty > 0 {
// before doing pow we need the pubkey
if bunker != nil {
evt.PubKey, err = bunker.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("can't pow: failed to get public key from bunker: %w", err)
}
} else if numSigners := c.Uint("musig"); numSigners > 1 && sec != "" {
if numSigners := c.Uint("musig"); numSigners > 1 {
pubkeys := c.StringSlice("musig-pubkey")
if int(numSigners) != len(pubkeys) {
return fmt.Errorf("when doing a pow with musig we must know all signer pubkeys upfront")
@@ -266,20 +268,19 @@ example:
return err
}
} else {
evt.PubKey, _ = nostr.GetPublicKey(sec)
evt.PubKey, _ = kr.GetPublicKey(ctx)
}
// try to generate work with this difficulty -- essentially forever
nip13.Generate(&evt, int(difficulty), time.Hour*24*365)
// try to generate work with this difficulty -- runs forever
nonceTag, _ := nip13.DoWork(ctx, evt, int(difficulty))
evt.Tags = append(evt.Tags, nonceTag)
mustRehashAndResign = true
}
if evt.Sig == "" || mustRehashAndResign {
if bunker != nil {
if err := bunker.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign with bunker: %w", err)
}
} else if numSigners := c.Uint("musig"); numSigners > 1 && sec != "" {
if evt.Sig == [64]byte{} || mustRehashAndResign {
if numSigners := c.Uint("musig"); numSigners > 1 {
// must do musig
pubkeys := c.StringSlice("musig-pubkey")
secNonce := c.String("musig-nonce-secret")
pubNonces := c.StringSlice("musig-nonce")
@@ -294,7 +295,10 @@ example:
// instructions for what to do should have been printed by the performMusig() function
return nil
}
} else if err := evt.Sign(sec); err != 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)
}
}
@@ -310,56 +314,12 @@ example:
}
stdout(result)
// publish to relays
successRelays := make([]string, 0, len(relays))
if len(relays) > 0 {
os.Stdout.Sync()
for _, relay := range relays {
publish:
log("publishing to %s... ", relay.URL)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return publishFlow(ctx, c, kr, evt, relays)
}
err := relay.Publish(ctx, evt)
if err == nil {
// published fine
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
if strings.HasPrefix(err.Error(), "msg: auth-required:") && (sec != "" || bunker != nil) && doAuth {
// if the relay is requesting auth and we can auth, let's do it
var pk string
if bunker != nil {
pk, err = bunker.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get public key from bunker: %w", err)
}
} else {
pk, _ = nostr.GetPublicKey(sec)
}
log("performing auth as %s... ", pk)
if err := relay.Auth(ctx, func(evt *nostr.Event) error {
if bunker != nil {
return bunker.SignEvent(ctx, evt)
}
return evt.Sign(sec)
}); err == nil {
// try to publish again, but this time don't try to auth again
doAuth = false
goto publish
} else {
log("auth error: %s. ", err)
}
}
log("failed: %s\n", err)
}
if len(successRelays) > 0 && c.Bool("nevent") {
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
log(nevent + "\n")
}
for stdinEvent := range getJsonsOrBlank() {
if err := handleEvent(stdinEvent); err != nil {
ctx = lineProcessingError(ctx, err.Error())
}
}
@@ -367,3 +327,132 @@ example:
return nil
},
}
func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr.Event, relays []*nostr.Relay) error {
doAuth := c.Bool("auth")
// publish to relays
successRelays := make([]string, 0, len(relays))
if len(relays) > 0 {
os.Stdout.Sync()
if c.Bool("confirm") {
relaysStr := make([]string, len(relays))
for i, r := range relays {
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
}
time.Sleep(time.Millisecond * 10)
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
return nil
}
}
if supportsDynamicMultilineMagic() {
// overcomplicated multiline rendering magic
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
urls := make([]string, len(relays))
lines := make([][][]byte, len(urls))
flush := func() {
for _, line := range lines {
for _, part := range line {
os.Stderr.Write(part)
}
os.Stderr.Write([]byte{'\n'})
}
}
render := func() {
clearLines(len(lines))
flush()
}
flush()
logthis := func(relayUrl, s string, args ...any) {
idx := slices.Index(urls, relayUrl)
lines[idx] = append(lines[idx], []byte(fmt.Sprintf(s, args...)))
render()
}
colorizethis := func(relayUrl string, colorize func(string, ...any) string) {
cleanUrl, _ := strings.CutPrefix(relayUrl, "wss://")
idx := slices.Index(urls, relayUrl)
lines[idx][0] = []byte(fmt.Sprintf("publishing to %s... ", colorize(cleanUrl)))
render()
}
for i, relay := range relays {
urls[i] = relay.URL
lines[i] = make([][]byte, 1, 3)
colorizethis(relay.URL, color.CyanString)
}
render()
for res := range sys.Pool.PublishMany(ctx, urls, evt) {
if res.Error == nil {
colorizethis(res.RelayURL, colors.successf)
logthis(res.RelayURL, "success.")
successRelays = append(successRelays, res.RelayURL)
} else {
colorizethis(res.RelayURL, colors.errorf)
// in this case it's likely that the lowest-level error is the one that will be more helpful
low := unwrapAll(res.Error)
// hack for some messages such as from relay.westernbtc.com
msg := strings.ReplaceAll(low.Error(), evt.PubKey.Hex(), "author")
// do not allow the message to overflow the term window
msg = clampMessage(msg, 20+len(res.RelayURL))
logthis(res.RelayURL, msg)
}
}
} else {
// normal dumb flow
for 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
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth {
// if the relay is requesting auth and we can auth, let's do it
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
if err := relay.Auth(ctx, kr.SignEvent); err == nil {
// try to publish again, but this time don't try to auth again
doAuth = false
goto publish
} else {
log("auth error: %s. ", err)
}
}
log("failed: %s\n", err)
}
}
if len(successRelays) > 0 && c.Bool("nevent") {
log(nip19.EncodeNevent(evt.ID, successRelays, evt.PubKey) + "\n")
}
}
return nil
}

View File

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

View File

@@ -2,17 +2,18 @@ package main
import (
"context"
"fmt"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip05"
"github.com/nbd-wtf/go-nostr/nip19"
sdk "github.com/nbd-wtf/nostr-sdk"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk/hints"
"github.com/urfave/cli/v3"
)
var fetch = &cli.Command{
Name: "fetch",
Usage: "fetches events related to the given nip19 or nip05 code from the included relay hints or the author's NIP-65 relays.",
Usage: "fetches events related to the given nip19 or nip05 code from the included relay hints or the author's outbox relays.",
Description: `example usage:
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
@@ -26,18 +27,9 @@ var fetch = &cli.Command{
),
ArgsUsage: "[nip05_or_nip19_code]",
Action: func(ctx context.Context, c *cli.Command) error {
sys := sdk.NewSystem()
defer func() {
sys.Pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
relay.Close()
return true
})
}()
for code := range getStdinLinesOrArguments(c.Args()) {
filter := nostr.Filter{}
var authorHint string
var authorHint nostr.PubKey
relays := c.StringSlice("relay")
if nip05.IsValidIdentifier(code) {
@@ -64,12 +56,15 @@ var fetch = &cli.Command{
case "nevent":
v := value.(nostr.EventPointer)
filter.IDs = append(filter.IDs, v.ID)
if v.Author != "" {
if v.Author != nostr.ZeroPK {
authorHint = v.Author
}
relays = append(relays, v.Relays...)
case "note":
filter.IDs = append(filter.IDs, value.([32]byte))
case "naddr":
v := value.(nostr.EntityPointer)
filter.Kinds = []nostr.Kind{v.Kind}
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
filter.Authors = append(filter.Authors, v.PublicKey)
authorHint = v.PublicKey
@@ -80,20 +75,21 @@ var fetch = &cli.Command{
authorHint = v.PublicKey
relays = append(relays, v.Relays...)
case "npub":
v := value.(string)
v := value.(nostr.PubKey)
filter.Authors = append(filter.Authors, v)
authorHint = v
default:
return fmt.Errorf("unexpected prefix %s", prefix)
}
}
if authorHint != "" {
relays := sys.FetchOutboxRelays(ctx, authorHint, 3)
if authorHint != nostr.ZeroPK {
for _, url := range relays {
relays = append(relays, url)
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
}
if len(filter.Kinds) == 0 {
filter.Kinds = append(filter.Kinds, 0)
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 3) {
relays = append(relays, url)
}
}
@@ -101,12 +97,18 @@ var fetch = &cli.Command{
return err
}
if len(filter.Authors) > 0 && len(filter.Kinds) == 0 {
filter.Kinds = append(filter.Kinds, 0)
}
if len(relays) == 0 {
ctx = lineProcessingError(ctx, "no relay hints found")
continue
}
for ie := range sys.Pool.SubManyEose(ctx, relays, nostr.Filters{filter}) {
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-fetch",
}) {
stdout(ie.Event)
}
}

95
filter.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"context"
"fmt"
"fiatjaf.com/nostr"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
var filter = &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: reqFilterFlags,
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
}
// 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) {
stdout(evt)
} else {
logverbose("event %s didn't match %s", evt, baseFilter)
}
}
exitIfLineProcessingError(ctx)
return nil
},
}

136
flags.go
View File

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

123
fs.go Normal file
View File

@@ -0,0 +1,123 @@
//go:build !windows && !openbsd
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
"github.com/fiatjaf/nak/nostrfs"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/urfave/cli/v3"
)
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))
timeout := time.Second * 120
server, err := fs.Mount(mountpoint, root, &fs.Options{
MountOptions: fuse.MountOptions{
Debug: isVerbose,
Name: "nak",
FsName: "nak",
RememberInodes: true,
},
AttrTimeout: &timeout,
EntryTimeout: &timeout,
Logger: nostr.DebugLogger,
})
if err != nil {
return fmt.Errorf("mount failed: %w", err)
}
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... ")
err := server.Unmount()
if err != nil {
chErr <- fmt.Errorf("unmount failed: %w", err)
} else {
log("ok\n")
chErr <- nil
}
}()
// serve the filesystem until unmounted
server.Wait()
return <-chErr
},
}

20
fs_other.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build windows || 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 Windows and OpenBSD.")
},
}

192
gift.go Normal file
View File

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

1543
git.go Normal file

File diff suppressed because it is too large Load Diff

119
go.mod
View File

@@ -1,63 +1,106 @@
module github.com/fiatjaf/nak
go 1.22
toolchain go1.22.4
go 1.25
require (
fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.0-20251204122254-07061404918d
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.3
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.3.0
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.16.0
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae
github.com/fiatjaf/eventstore v0.7.1
github.com/fiatjaf/khatru v0.7.5
github.com/mailru/easyjson v0.7.7
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.1
github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3
github.com/nbd-wtf/go-nostr v0.34.8
github.com/nbd-wtf/nostr-sdk v0.5.0
golang.org/x/exp v0.0.0-20240707233637-46b078467d37
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-tty v0.0.7
github.com/mdp/qrterminal/v3 v3.2.1
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.0.0-beta1
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/sync v0.18.0
golang.org/x/term v0.32.0
)
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/btcsuite/btcd/btcutil v1.1.3 // indirect
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bluekeyes/go-gitdiff v0.7.1 // indirect
github.com/btcsuite/btcd v0.24.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/chzyer/logex v1.1.10 // indirect
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/pie/v2 v2.7.0 // indirect
github.com/fasthttp/websocket v1.5.7 // indirect
github.com/fiatjaf/generic-ristretto v0.0.1 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/golang/glog v1.1.2 // indirect
github.com/graph-gophers/dataloader/v7 v7.1.0 // indirect
github.com/elnosh/gonuts v0.4.2 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.1.0 // indirect
github.com/rs/cors v1.7.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tetratelabs/wazero v1.2.1 // indirect
github.com/tidwall/gjson v1.17.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/templexxx/cpu v0.0.1 // indirect
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect
github.com/tetratelabs/wazero v1.8.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.35.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
)

299
go.sum
View File

@@ -1,19 +1,51 @@
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-20251201232830-91548fa0a157 h1:14yLsO2HwpS2CLIKFvLMDp8tVEDahwdC8OeG6NGaL+M=
fiatjaf.com/nostr v0.0.0-20251201232830-91548fa0a157/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
fiatjaf.com/nostr v0.0.0-20251204122254-07061404918d h1:xROmiuT7LrZk+/iGGeTqRI4liqJZrc87AWjsyHtbqDg=
fiatjaf.com/nostr v0.0.0-20251204122254-07061404918d/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg=
github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
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.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
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.3 h1:6+iXlDKE8RMtKsvK0gshlXIuPbyWM/h84Ensb7o3sC0=
github.com/btcsuite/btcd/btcec/v2 v2.3.3/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.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ=
github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
@@ -27,53 +59,70 @@ 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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/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/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KHau4=
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
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=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae h1:0B/1dU3YECIbPoBIRTQ4c0scZCNz9TVHtQpiODGrTTo=
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae/go.mod h1:aAWPO4bixZZxPtOnH6K3q4GbQ0jftUNDW9Oa861IRew=
github.com/fiatjaf/eventstore v0.7.1 h1:5f2yvEtYvsvMBNttysmXhSSum5M1qwvPzjEQ/BFue7Q=
github.com/fiatjaf/eventstore v0.7.1/go.mod h1:ek/yWbanKVG767fK51Q3+6Mvi5oEHYSsdPym40nZexw=
github.com/fiatjaf/generic-ristretto v0.0.1 h1:LUJSU87X/QWFsBXTwnH3moFe4N8AjUxT+Rfa0+bo6YM=
github.com/fiatjaf/generic-ristretto v0.0.1/go.mod h1:cvV6ANHDA/GrfzVrig7N7i6l8CWnkVZvtQ2/wk9DPVE=
github.com/fiatjaf/khatru v0.7.5 h1:UFo+cdbqHDn1W4Q4h03y3vzh1BiU+6fLYK48oWU2K34=
github.com/fiatjaf/khatru v0.7.5/go.mod h1:WVqij7X9Vr9UAMIwafQbKVFKxc42Np37vyficwUr/nQ=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo=
github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -85,12 +134,24 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
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/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc=
github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q=
github.com/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.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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
@@ -99,24 +160,59 @@ github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJS
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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/nbd-wtf/go-nostr v0.34.8 h1:wExcoeaFX5DZKzZruQlrEYVd59Z6GmRyPCltTuoyn9g=
github.com/nbd-wtf/go-nostr v0.34.8/go.mod h1:NZQkxl96ggbO8rvDpVjcsojJqKTPwqhP4i82O7K5DJs=
github.com/nbd-wtf/nostr-sdk v0.5.0 h1:zrMxcvMSxkw29RyfXEdF3XW5rUWLuT5Q9oBAhd5dyew=
github.com/nbd-wtf/nostr-sdk v0.5.0/go.mod h1:MJ7gYv3XiZKU6MHSM0N7oHqQAQhbvpgGQk4Q+XUdIUs=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=
github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
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=
@@ -130,51 +226,92 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.1.0 h1:EewKT7/LNac5SLiEblJeUu8z5eERHrmRLnMQL2d7qX4=
github.com/puzpuzpuz/xsync/v3 v3.1.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
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/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/tetratelabs/wazero v1.2.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs=
github.com/tetratelabs/wazero v1.2.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
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/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/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=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -182,16 +319,28 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-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.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -201,6 +350,8 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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=
@@ -209,3 +360,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -3,90 +3,133 @@ package main
import (
"bufio"
"context"
"encoding/hex"
"errors"
"fmt"
"iter"
"math/rand"
"net/http"
"net/textproto"
"net/url"
"os"
"runtime"
"slices"
"strings"
"sync"
"time"
"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"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46"
"github.com/nbd-wtf/go-nostr/nip49"
jsoniter "github.com/json-iterator/go"
"github.com/mattn/go-isatty"
"github.com/mattn/go-tty"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
var sys *sdk.System
var json = jsoniter.ConfigFastest
const (
LINE_PROCESSING_ERROR = iota
)
var log = func(msg string, args ...any) {
fmt.Fprintf(color.Error, msg, args...)
}
var stdout = fmt.Println
var (
log = func(msg string, args ...any) { fmt.Fprintf(color.Error, msg, args...) }
logverbose = func(msg string, args ...any) {} // by default do nothing
stdout = func(args ...any) { fmt.Fprintln(color.Output, args...) }
)
func isPiped() bool {
stat, _ := os.Stdin.Stat()
return stat.Mode()&os.ModeCharDevice == 0
}
func getStdinLinesOrBlank() chan string {
multi := make(chan string)
if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines {
single := make(chan string, 1)
single <- ""
close(single)
return single
} else {
return multi
func getJsonsOrBlank() iter.Seq[string] {
var curr strings.Builder
var finalJsonErr error
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
// we'll try the next line until we're successful
curr.WriteString(stdinLine)
stdinEvent := curr.String()
var dummy any
if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil {
finalJsonErr = err
return true
}
finalJsonErr = nil
if !yield(stdinEvent) {
return false
}
curr.Reset()
return true
})
if !hasStdin && !isPiped() {
yield("{}")
}
if finalJsonErr != nil {
log(color.YellowString("stdin json parse error: %s", finalJsonErr))
}
}
}
func getStdinLinesOrArguments(args cli.Args) chan string {
func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
return false
}
return true
})
if !hasStdin {
yield("")
}
}
}
func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] {
return getStdinLinesOrArgumentsFromSlice(args.Slice())
}
func getStdinLinesOrArgumentsFromSlice(args []string) chan string {
func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] {
// try the first argument
if len(args) > 0 {
argsCh := make(chan string, 1)
go func() {
for _, arg := range args {
argsCh <- arg
}
close(argsCh)
}()
return argsCh
return slices.Values(args)
}
// try the stdin
multi := make(chan string)
writeStdinLinesOrNothing(multi)
return multi
return func(yield func(string) bool) {
writeStdinLinesOrNothing(yield)
}
}
func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) {
func writeStdinLinesOrNothing(yield func(string) bool) (hasStdinLines bool) {
if isPiped() {
// piped
go func() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
hasEmittedAtLeastOne := false
for scanner.Scan() {
ch <- strings.TrimSpace(scanner.Text())
hasEmittedAtLeastOne = true
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
hasEmittedAtLeastOne := false
for scanner.Scan() {
if !yield(strings.TrimSpace(scanner.Text())) {
return
}
if !hasEmittedAtLeastOne {
ch <- ""
}
close(ch)
}()
return true
hasEmittedAtLeastOne = true
}
return hasEmittedAtLeastOne
} else {
// not piped
return false
@@ -117,56 +160,199 @@ func normalizeAndValidateRelayURLs(wsurls []string) error {
func connectToAllRelays(
ctx context.Context,
c *cli.Command,
relayUrls []string,
forcePreAuth bool,
opts ...nostr.PoolOption,
) (*nostr.SimplePool, []*nostr.Relay) {
relays := make([]*nostr.Relay, 0, len(relayUrls))
pool := nostr.NewSimplePool(ctx, opts...)
relayLoop:
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 {
log("connecting to %s... ", url)
if relay, err := pool.EnsureRelay(url); err == nil {
if forcePreAuth {
log("waiting for auth challenge... ")
signer := opts[0].(nostr.WithAuthHandler)
time.Sleep(time.Millisecond * 200)
challengeWaitLoop:
for {
// beginhack
// here starts the biggest and ugliest hack of this codebase
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.GetFirst([]string{"challenge", ""})
if (*challengeTag)[1] == "" {
return fmt.Errorf("auth not received yet *****")
}
return signer(authEvent)
}); err == nil {
// auth succeeded
break challengeWaitLoop
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second)
continue challengeWaitLoop
} else {
// it failed for some other reason, so skip this relay
log(err.Error() + "\n")
continue relayLoop
}
}
// endhack
}
}
relays = append(relays, relay)
log("ok.\n")
} else {
log(err.Error() + "\n")
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
log("invalid relay URL: %s\n", url)
os.Exit(4)
}
}
return pool, relays
opts.EventMiddleware = sys.TrackEventHints
opts.PenaltyBox = true
opts.RelayOptions = nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}},
}
sys.Pool = nostr.NewPool(opts)
relays := make([]*nostr.Relay, 0, len(relayUrls))
if supportsDynamicMultilineMagic() {
// overcomplicated multiline rendering magic
lines := make([][][]byte, len(relayUrls))
flush := func() {
for _, line := range lines {
for _, part := range line {
os.Stderr.Write(part)
}
os.Stderr.Write([]byte{'\n'})
}
}
render := func() {
clearLines(len(lines))
flush()
}
flush()
wg := sync.WaitGroup{}
wg.Add(len(relayUrls))
for i, url := range relayUrls {
lines[i] = make([][]byte, 1, 2)
logthis := func(s string, args ...any) {
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
render()
}
colorizepreamble := func(c func(string, ...any) string) {
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
}
colorizepreamble(color.CyanString)
go func() {
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, colorizepreamble, logthis)
if relay != nil {
relays = append(relays, relay)
}
wg.Done()
}()
}
wg.Wait()
} else {
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(url))
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
if relay != nil {
relays = append(relays, relay)
}
log("\n")
}
}
return relays
}
func connectToSingleRelay(
ctx context.Context,
c *cli.Command,
url string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error),
colorizepreamble func(c func(string, ...any) string),
logthis func(s string, args ...any),
) *nostr.Relay {
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
if preAuthSigner != nil {
if colorizepreamble != nil {
colorizepreamble(color.YellowString)
}
logthis("waiting for auth challenge... ")
time.Sleep(time.Millisecond * 200)
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
}); err == nil {
// auth succeeded
goto preauthSuccess
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second)
continue
} else {
// it failed for some other reason, so skip this relay
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis(err.Error())
return nil
}
}
}
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis("failed to get an AUTH challenge in enough time.")
return nil
}
preauthSuccess:
if colorizepreamble != nil {
colorizepreamble(colors.successf)
}
logthis("ok.")
return relay
} else {
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
// if we're here that means we've failed to connect, this may be a huge message
// but we're likely to only be interested in the lowest level error (although we can leave space)
logthis(clampError(err, len(url)+12))
return nil
}
}
func clearLines(lineCount int) {
for i := 0; i < lineCount; i++ {
os.Stderr.Write([]byte("\033[0A\033[2K\r"))
}
}
func supportsDynamicMultilineMagic() bool {
if runtime.GOOS == "windows" {
return false
}
if !term.IsTerminal(0) {
return false
}
if !isatty.IsTerminal(os.Stdout.Fd()) {
return false
}
width, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil {
return false
}
if width < 110 {
return false
}
return true
}
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
defer func() {
if err != nil {
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
log("%s auth failed: %s", colors.errorf(cleanUrl), err)
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth required, but --auth flag not given")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
return kr.SignEvent(ctx, authEvent)
}
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
@@ -180,106 +366,6 @@ func exitIfLineProcessingError(ctx context.Context) {
}
}
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (string, *nip46.BunkerClient, error) {
var err error
if bunkerURL := c.String("connect"); bunkerURL != "" {
clientKey := c.String("connect-as")
if clientKey != "" {
clientKey = strings.Repeat("0", 64-len(clientKey)) + clientKey
} else {
clientKey = nostr.GeneratePrivateKey()
}
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, nil, func(s string) {
fmt.Fprintf(color.Error, color.CyanString("[nip46]: open the following URL: %s"), s)
})
return "", bunker, err
}
sec := c.String("sec")
// check in the environment for the secret key
if sec == "" {
if key, ok := os.LookupEnv("NOSTR_PRIVATE_KEY"); ok {
sec = key
}
}
if c.Bool("prompt-sec") {
if isPiped() {
return "", nil, fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
}
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
if err != nil {
return "", nil, fmt.Errorf("failed to get secret key: %w", err)
}
}
if strings.HasPrefix(sec, "ncryptsec1") {
sec, err = promptDecrypt(sec)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt: %w", err)
}
} else if bsec, err := hex.DecodeString(leftPadKey(sec)); err == nil {
sec = hex.EncodeToString(bsec)
} else if prefix, hexvalue, err := nip19.Decode(sec); err != nil {
return "", nil, fmt.Errorf("invalid nsec: %w", err)
} else if prefix == "nsec" {
sec = hexvalue.(string)
}
if ok := nostr.IsValid32ByteHex(sec); !ok {
return "", nil, fmt.Errorf("invalid secret key")
}
return sec, nil, nil
}
func promptDecrypt(ncryptsec string) (string, error) {
for i := 1; i < 4; i++ {
var attemptStr string
if i > 1 {
attemptStr = fmt.Sprintf(" [%d/3]", i)
}
password, err := askPassword("type the password to decrypt your secret key"+attemptStr+": ", nil)
if err != nil {
return "", err
}
sec, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
continue
}
return sec, nil
}
return "", fmt.Errorf("couldn't decrypt private key")
}
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
config := &readline.Config{
Stdout: color.Error,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: true,
MaskRune: '*',
}
rl, err := readline.NewEx(config)
if err != nil {
return "", err
}
for {
answer, err := rl.Readline()
if err != nil {
return "", err
}
answer = strings.TrimSpace(answer)
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, err
}
}
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func randString(n int) string {
@@ -290,6 +376,177 @@ func randString(n int) string {
return string(b)
}
func leftPadKey(k string) string {
return strings.Repeat("0", 64-len(k)) + k
func unwrapAll(err error) error {
low := err
for n := low; n != nil; n = errors.Unwrap(low) {
low = n
}
return low
}
func clampMessage(msg string, prefixAlreadyPrinted int) string {
termSize, _, _ := term.GetSize(int(os.Stderr.Fd()))
prf := "expected handshake response status code 101 but got "
if len(msg) > len(prf) && msg[0:len(prf)] == prf {
msg = "status " + msg[len(prf):]
}
if len(msg) > termSize-prefixAlreadyPrinted && prefixAlreadyPrinted+1 < termSize {
msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…"
}
return msg
}
func clampError(err error, prefixAlreadyPrinted int) string {
termSize, _, _ := term.GetSize(0)
msg := err.Error()
if len(msg) > termSize-prefixAlreadyPrinted {
err = unwrapAll(err)
msg = clampMessage(err.Error(), prefixAlreadyPrinted)
}
return msg
}
func appendUnique[A comparable](list []A, newEls ...A) []A {
ex:
for _, newEl := range newEls {
for _, el := range list {
if el == newEl {
continue ex
}
}
list = append(list, newEl)
}
return list
}
func askConfirmation(msg string) bool {
if isPiped() {
tty, err := tty.Open()
if err != nil {
return false
}
defer tty.Close()
log(color.YellowString(msg))
answer, err := tty.ReadString()
if err != nil {
return false
}
// print newline after password input
fmt.Fprintln(os.Stderr)
answer = strings.TrimSpace(string(answer))
return answer == "y" || answer == "yes"
} else {
config := &readline.Config{
Stdout: color.Error,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: false,
MaskRune: '*',
}
rl, err := readline.NewEx(config)
if err != nil {
return false
}
answer, err := rl.Readline()
if err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
}
func parsePubKey(value string) (nostr.PubKey, error) {
// try nip05 first
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err == nil {
return pp.PublicKey, nil
}
// if nip05 fails, fall through to try as pubkey
}
pk, err := nostr.PubKeyFromHex(value)
if err == nil {
return pk, nil
}
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "npub":
if pk, ok := decoded.(nostr.PubKey); ok {
return pk, nil
}
case "nprofile":
if profile, ok := decoded.(nostr.ProfilePointer); ok {
return profile.PublicKey, nil
}
}
}
return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value)
}
func parseEventID(value string) (nostr.ID, error) {
id, err := nostr.IDFromHex(value)
if err == nil {
return id, nil
}
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "note":
if id, ok := decoded.(nostr.ID); ok {
return id, nil
}
case "nevent":
if event, ok := decoded.(nostr.EventPointer); ok {
return event.ID, nil
}
}
}
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
}
func decodeTagValue(value string) string {
if strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1") || strings.HasPrefix(value, "nprofile1") || strings.HasPrefix(value, "naddr1") {
if ptr, err := nip19.ToPointer(value); err == nil {
return ptr.AsTagReference()
}
}
return value
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{
color.New(color.Reset).Print,
color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,
color.New(color.Bold, color.FgHiGreen).Sprintf,
}

194
helpers_key.go Normal file
View File

@@ -0,0 +1,194 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip46"
"fiatjaf.com/nostr/nip49"
"github.com/chzyer/readline"
"github.com/fatih/color"
"github.com/mattn/go-tty"
"github.com/urfave/cli/v3"
)
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 '01'",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: defaultKey,
HideDefault: true,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to use when communicating with nip46 bunkers",
DefaultText: "a random key",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
},
}
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, nostr.SecretKey, error) {
key, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return nil, nostr.SecretKey{}, err
}
var kr nostr.Keyer
if bunker != nil {
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
} else {
kr = keyer.NewPlainKeySigner(key)
}
return kr, key, nil
}
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (nostr.SecretKey, *nip46.BunkerClient, error) {
sec := c.String("sec")
if strings.HasPrefix(sec, "bunker://") {
// it's a bunker
bunkerURL := sec
clientKeyHex := c.String("connect-as")
var clientKey nostr.SecretKey
if clientKeyHex != "" {
var err error
clientKey, err = nostr.SecretKeyFromHex(clientKeyHex)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("bunker client key '%s' is invalid: %w", clientKeyHex, err)
}
} else {
clientKey = nostr.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
}
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 nostr.SecretKey{}, nil, fmt.Errorf("failed to get secret key: %w", err)
}
}
if strings.HasPrefix(sec, "ncryptsec1") {
sk, err := promptDecrypt(sec)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("failed to decrypt: %w", err)
}
return sk, nil, nil
}
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
return ski.(nostr.SecretKey), nil, nil
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("invalid secret key: %w", err)
}
return sk, nil, nil
}
func promptDecrypt(ncryptsec string) (nostr.SecretKey, error) {
for i := 1; i < 4; i++ {
var attemptStr string
if i > 1 {
attemptStr = fmt.Sprintf(" [%d/3]", i)
}
password, err := askPassword("type the password to decrypt your secret key"+attemptStr+": ", nil)
if err != nil {
return nostr.SecretKey{}, err
}
sec, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
continue
}
return sec, nil
}
return nostr.SecretKey{}, fmt.Errorf("couldn't decrypt private key")
}
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
if isPiped() {
// use TTY method when stdin is piped
tty, err := tty.Open()
if err != nil {
return "", fmt.Errorf("can't prompt for a secret key when processing data from a pipe on this system (failed to open /dev/tty: %w), try again without --prompt-sec or provide the key via --sec or NOSTR_SECRET_KEY environment variable", err)
}
defer tty.Close()
for {
// print the prompt to stderr so it's visible to the user
log(color.YellowString(msg))
// read password from TTY with masking
password, err := tty.ReadPassword()
if err != nil {
return "", err
}
// print newline after password input
fmt.Fprintln(os.Stderr)
answer := strings.TrimSpace(string(password))
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, nil
}
} else {
// use normal readline method when stdin is not piped
config := &readline.Config{
Stdout: os.Stderr,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: true,
MaskRune: '*',
}
rl, err := readline.NewEx(config)
if err != nil {
return "", err
}
for {
answer, err := rl.Readline()
if err != nil {
return "", err
}
answer = strings.TrimSpace(answer)
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, err
}
}
}

5
justfile Normal file
View File

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

83
key.go
View File

@@ -3,30 +3,28 @@ package main
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip49"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip49"
"github.com/urfave/cli/v3"
)
var key = &cli.Command{
Name: "key",
Usage: "operations on secret keys: generate, derive, encrypt, decrypt.",
Usage: "operations on secret keys: generate, derive, encrypt, decrypt",
Description: ``,
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
generate,
public,
encrypt,
decrypt,
encryptKey,
decryptKey,
combine,
},
}
@@ -37,8 +35,8 @@ var generate = &cli.Command{
Description: ``,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
sec := nostr.GeneratePrivateKey()
stdout(sec)
sec := nostr.Generate()
stdout(sec.Hex())
return nil
},
}
@@ -49,23 +47,30 @@ var public = &cli.Command{
Description: ``,
ArgsUsage: "[secret]",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "with-parity",
Usage: "output 33 bytes instead of 32, the first one being either '02' or '03', a prefix indicating whether this pubkey is even or odd.",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for sec := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
pubkey, err := nostr.GetPublicKey(sec)
if err != nil {
ctx = lineProcessingError(ctx, "failed to derive public key: %s", err)
continue
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
_, pk := btcec.PrivKeyFromBytes(sk[:])
if c.Bool("with-parity") {
stdout(hex.EncodeToString(pk.SerializeCompressed()))
} else {
stdout(hex.EncodeToString(pk.SerializeCompressed()[1:]))
}
stdout(pubkey)
}
return nil
},
}
var encrypt = &cli.Command{
var encryptKey = &cli.Command{
Name: "encrypt",
Usage: "encrypts a secret key and prints an ncryptsec code",
Description: `uses the NIP-49 standard.`,
Description: `uses the nip49 standard.`,
ArgsUsage: "<secret> <password>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
@@ -101,10 +106,10 @@ var encrypt = &cli.Command{
},
}
var decrypt = &cli.Command{
var decryptKey = &cli.Command{
Name: "decrypt",
Usage: "takes an ncrypsec and a password and decrypts it into an nsec",
Description: `uses the NIP-49 standard.`,
Description: `uses the nip49 standard.`,
ArgsUsage: "<ncryptsec-code> <password>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
@@ -117,30 +122,30 @@ var decrypt = &cli.Command{
if password == "" {
return fmt.Errorf("no password given")
}
sec, err := nip49.Decrypt(ncryptsec, password)
sk, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
return fmt.Errorf("failed to decrypt: %s", err)
}
stdout(sec)
stdout(sk.Hex())
return nil
case 1:
if arg := c.Args().Get(0); strings.HasPrefix(arg, "ncryptsec1") {
ncryptsec = arg
if res, err := promptDecrypt(ncryptsec); err != nil {
if sk, err := promptDecrypt(ncryptsec); err != nil {
return err
} else {
stdout(res)
stdout(sk.Hex())
return nil
}
} else {
password = c.Args().Get(0)
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
sec, err := nip49.Decrypt(ncryptsec, password)
sk, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
continue
}
stdout(sec)
stdout(sk.Hex())
}
return nil
}
@@ -188,7 +193,7 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
for i, prefix := range []byte{0x02, 0x03} {
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing key %s: %s", keyhex, err)
log("error parsing key %s: %s", keyhex, err)
continue
}
group[i] = pubk
@@ -229,7 +234,7 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
agg, _, _, err := musig2.AggregateKeys(combining, true)
if err != nil {
fmt.Fprintf(os.Stderr, "error aggregating: %s", err)
log("error aggregating: %s", err)
return
}
@@ -252,33 +257,37 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
}
res, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(res))
stdout(string(res))
return nil
},
}
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, c *cli.Command, keys []string) chan string {
ch := make(chan string)
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan nostr.SecretKey {
ch := make(chan nostr.SecretKey)
go func() {
for sec := range getStdinLinesOrArgumentsFromSlice(keys) {
if sec == "" {
continue
}
var sk nostr.SecretKey
if strings.HasPrefix(sec, "nsec1") {
_, data, err := nip19.Decode(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid nsec code: %s", err)
continue
}
sec = data.(string)
sk = data.(nostr.SecretKey)
}
sec = leftPadKey(sec)
if !nostr.IsValid32ByteHex(sec) {
ctx = lineProcessingError(ctx, "invalid hex key")
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
ch <- sec
ch <- sk
}
close(ch)
}()

114
main.go
View File

@@ -2,54 +2,146 @@ package main
import (
"context"
"fmt"
"net/http"
"net/textproto"
"os"
"path/filepath"
"github.com/fiatjaf/cli/v3"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var (
version string = "debug"
isVerbose bool = false
)
var app = &cli.Command{
Name: "nak",
Suggest: true,
UseShortOptionHandling: true,
AllowFlagsAfterArguments: true,
Usage: "the nostr army knife command-line tool",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
req,
count,
fetch,
event,
req,
filter,
fetch,
count,
decode,
encode,
key,
verify,
relay,
admin,
bunker,
serve,
blossomCmd,
dekey,
encrypt,
decrypt,
gift,
outbox,
wallet,
mcpServer,
curl,
fsCmd,
publish,
git,
nip,
syncCmd,
},
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 filepath.Join("/dev/null")
}
})(),
},
&cli.BoolFlag{
Name: "quiet",
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
Aliases: []string{"q"},
Persistent: true,
Name: "quiet",
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
Aliases: []string{"q"},
Action: func(ctx context.Context, c *cli.Command, b bool) error {
q := c.Count("quiet")
if q >= 1 {
log = func(msg string, args ...any) {}
if q >= 2 {
stdout = func(_ ...any) (int, error) { return 0, nil }
stdout = func(_ ...any) {}
}
}
return nil
},
},
&cli.BoolFlag{
Name: "verbose",
Usage: "print more stuff than normally",
Aliases: []string{"v"},
Action: func(ctx context.Context, c *cli.Command, b bool) error {
v := c.Count("verbose")
if v >= 1 {
logverbose = log
isVerbose = true
}
return nil
},
},
},
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)
}
sys.Pool = nostr.NewPool(nostr.PoolOptions{
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
EventMiddleware: sys.TrackEventHints,
RelayOptions: nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
},
})
return ctx, nil
},
}
func init() {
cli.VersionFlag = &cli.BoolFlag{
Name: "version",
Usage: "prints the version",
}
}
func main() {
defer colors.reset()
// 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")
}
colors.reset()
os.Exit(1)
}
return
}
if err := app.Run(context.Background(), os.Args); err != nil {
stdout(err)
if err != nil {
log("%s\n", color.RedString(err.Error()))
}
colors.reset()
os.Exit(1)
}
}

268
mcp.go Normal file
View File

@@ -0,0 +1,268 @@
package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/urfave/cli/v3"
)
var mcpServer = &cli.Command{
Name: "mcp",
Usage: "pander to the AI gods",
Description: ``,
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
),
Action: func(ctx context.Context, c *cli.Command) error {
s := server.NewMCPServer(
"nak",
version,
)
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("content", mcp.Description("Arbitrary string to be published"), mcp.Required()),
mcp.WithString("relay", mcp.Description("Relay to publish the note to")),
mcp.WithString("mention", mcp.Description("Nostr user's public key to be mentioned")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content := required[string](r, "content")
mention, _ := optional[string](r, "mention")
relay, _ := optional[string](r, "relay")
var relays []string
evt := nostr.Event{
Kind: 1,
Tags: nostr.Tags{{"client", "goose/nak"}},
Content: content,
CreatedAt: nostr.Now(),
}
if mention != "" {
pk, err := nostr.PubKeyFromHex(mention)
if err != nil {
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
}
evt.Tags = append(evt.Tags, nostr.Tag{"p", pk.Hex()})
// their inbox relays
relays = sys.FetchInboxRelays(ctx, pk, 3)
}
if err := keyer.SignEvent(ctx, &evt); err != nil {
return mcp.NewToolResultError("it was impossible to sign the event, so we can't proceed to publishwith publishing it."), nil
}
// our write relays
relays = append(relays, sys.FetchOutboxRelays(ctx, evt.PubKey, 3)...)
if len(relays) == 0 {
relays = []string{"nos.lol", "relay.damus.io"}
}
// extra relay specified
if relay != "" {
relays = append(relays, relay)
}
result := strings.Builder{}
result.WriteString(
fmt.Sprintf("the event we generated has id '%s', kind '%d' and is signed by pubkey '%s'. ",
evt.ID,
evt.Kind,
evt.PubKey,
),
)
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
if res.Error != nil {
result.WriteString(
fmt.Sprintf("there was an error publishing the event to the relay %s. ",
res.RelayURL),
)
} else {
result.WriteString(
fmt.Sprintf("the event was successfully published to the relay %s. ",
res.RelayURL),
)
}
}
return mcp.NewToolResultText(result.String()), nil
})
s.AddTool(mcp.NewTool("resolve_nostr_uri",
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
mcp.WithString("uri", mcp.Description("URI to be resolved"), mcp.Required()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri := required[string](r, "uri")
if strings.HasPrefix(uri, "nostr:") {
uri = uri[6:]
}
prefix, data, err := nip19.Decode(uri)
if err != nil {
return mcp.NewToolResultError("this Nostr uri is invalid"), nil
}
switch prefix {
case "npub":
pm := sys.FetchProfileMetadata(ctx, data.(nostr.PubKey))
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
pm.ShortName(), pm.PubKey),
), nil
case "nprofile":
pm, _ := sys.FetchProfileFromInput(ctx, uri)
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
pm.ShortName(), pm.PubKey),
), nil
case "nevent":
event, _, err := sys.FetchSpecificEventFromInput(ctx, uri, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != 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
default:
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")),
), 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{
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
}
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()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pubkey, err := nostr.PubKeyFromHex(required[string](r, "pubkey"))
if err != nil {
return mcp.NewToolResultError("the pubkey given isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
}
res := sys.FetchOutboxRelays(ctx, pubkey, 1)
return mcp.NewToolResultText(res[0]), nil
})
s.AddTool(mcp.NewTool("read_events_from_relay",
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
mcp.WithString("pubkey", mcp.Description("pubkey to include in the 'authors' field, if this is not given we will read any events from this relay")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relay := required[string](r, "relay")
kind := int(required[float64](r, "kind"))
limit := int(required[float64](r, "limit"))
pubkey, hasPubKey := optional[string](r, "pubkey")
filter := nostr.Filter{
Limit: limit,
Kinds: []nostr.Kind{nostr.Kind(kind)},
}
if hasPubKey {
if pk, err := nostr.PubKeyFromHex(pubkey); err != nil {
return mcp.NewToolResultError("the pubkey given isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile. Got error: " + err.Error()), nil
} else {
filter.Authors = append(filter.Authors, pk)
}
}
events := sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-profile-events",
})
result := strings.Builder{}
for ie := range events {
result.WriteString("author public key: ")
result.WriteString(ie.PubKey.Hex())
result.WriteString("content: '")
result.WriteString(ie.Content)
result.WriteString("'")
result.WriteString("\n---\n")
}
return mcp.NewToolResultText(result.String()), nil
})
return server.ServeStdio(s)
},
}
func required[T comparable](r mcp.CallToolRequest, p string) T {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero
}
if r.Params.Arguments[p].(T) == zero {
return zero
}
return r.Params.Arguments[p].(T)
}
func optional[T any](r mcp.CallToolRequest, p string) (T, bool) {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero, false
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero, false
}
return r.Params.Arguments[p].(T), true
}

View File

@@ -6,43 +6,42 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
"fiatjaf.com/nostr"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/nbd-wtf/go-nostr"
)
func getMusigAggregatedKey(_ context.Context, keys []string) (string, error) {
func getMusigAggregatedKey(_ context.Context, keys []string) (nostr.PubKey, error) {
knownSigners := make([]*btcec.PublicKey, len(keys))
for i, spk := range keys {
bpk, err := hex.DecodeString(spk)
if err != nil {
return "", fmt.Errorf("'%s' is invalid hex: %w", spk, err)
return nostr.ZeroPK, fmt.Errorf("'%s' is invalid hex: %w", spk, err)
}
if len(bpk) == 32 {
return "", fmt.Errorf("'%s' is missing the leading parity byte", spk)
return nostr.ZeroPK, fmt.Errorf("'%s' is missing the leading parity byte", spk)
}
pk, err := btcec.ParsePubKey(bpk)
if err != nil {
return "", fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
return nostr.ZeroPK, fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
}
knownSigners[i] = pk
}
aggpk, _, _, err := musig2.AggregateKeys(knownSigners, true)
if err != nil {
return "", fmt.Errorf("aggregation failed: %w", err)
return nostr.ZeroPK, fmt.Errorf("aggregation failed: %w", err)
}
return hex.EncodeToString(aggpk.FinalKey.SerializeCompressed()[1:]), nil
return nostr.PubKey(aggpk.FinalKey.SerializeCompressed()[1:]), nil
}
func performMusig(
_ context.Context,
sec string,
sec nostr.SecretKey,
evt *nostr.Event,
numSigners int,
keys []string,
@@ -51,11 +50,7 @@ func performMusig(
partialSigs []string,
) (signed bool, err error) {
// preprocess data received
secb, err := hex.DecodeString(sec)
if err != nil {
return false, err
}
seck, pubk := btcec.PrivKeyFromBytes(secb)
seck, pubk := btcec.PrivKeyFromBytes(sec[:])
knownSigners := make([]*btcec.PublicKey, 0, numSigners)
includesUs := false
@@ -135,8 +130,8 @@ func performMusig(
if err != nil {
return false, err
}
fmt.Fprintf(os.Stderr, "the following code should be saved secretly until the next step an included with --musig-nonce-secret:\n")
fmt.Fprintf(os.Stderr, "%s\n\n", base64.StdEncoding.EncodeToString(nonce.SecNonce[:]))
log("the following code should be saved secretly until the next step an included with --musig-nonce-secret:\n")
log("%s\n\n", base64.StdEncoding.EncodeToString(nonce.SecNonce[:]))
knownNonces = append(knownNonces, nonce.PubNonce)
printPublicCommandForNextPeer(evt, numSigners, knownSigners, knownNonces, nil, false)
@@ -147,9 +142,9 @@ func performMusig(
if comb, err := mctx.CombinedKey(); err != nil {
return false, fmt.Errorf("failed to combine keys (after %d signers): %w", len(knownSigners), err)
} else {
evt.PubKey = hex.EncodeToString(comb.SerializeCompressed()[1:])
evt.PubKey = nostr.PubKey(comb.SerializeCompressed()[1:])
evt.ID = evt.GetID()
fmt.Fprintf(os.Stderr, "combined key: %x\n\n", comb.SerializeCompressed())
log("combined key: %x\n\n", comb.SerializeCompressed())
}
// we have all the signers, which means we must also have all the nonces
@@ -201,11 +196,7 @@ func performMusig(
// signing phase
// we always have to sign, so let's do this
id := evt.GetID()
hash, _ := hex.DecodeString(id)
var msg32 [32]byte
copy(msg32[:], hash)
partialSig, err := session.Sign(msg32) // this will already include our sig in the bundle
partialSig, err := session.Sign(evt.GetID()) // this will already include our sig in the bundle
if err != nil {
return false, fmt.Errorf("failed to produce partial signature: %w", err)
}
@@ -226,7 +217,7 @@ func performMusig(
}
// we have the signature
evt.Sig = hex.EncodeToString(session.FinalSig().Serialize())
evt.Sig = [64]byte(session.FinalSig().Serialize())
return true, nil
}
@@ -244,7 +235,7 @@ func printPublicCommandForNextPeer(
maybeNonceSecret = " --musig-nonce-secret '<insert-nonce-secret>'"
}
fmt.Fprintf(os.Stderr, "the next signer and they should call this on their side:\nnak event --sec <insert-secret-key> --musig %d %s%s%s%s%s\n",
log("the next signer and they should call this on their side:\nnak event --sec <insert-secret-key> --musig %d %s%s%s%s%s\n",
numSigners,
eventToCliArgs(evt),
signersToCliArgs(knownSigners),
@@ -259,7 +250,7 @@ func eventToCliArgs(evt *nostr.Event) string {
b.Grow(100)
b.WriteString("-k ")
b.WriteString(strconv.Itoa(evt.Kind))
b.WriteString(strconv.Itoa(int(evt.Kind)))
b.WriteString(" -ts ")
b.WriteString(strconv.FormatInt(int64(evt.CreatedAt), 10))
@@ -270,7 +261,7 @@ func eventToCliArgs(evt *nostr.Event) string {
for _, tag := range evt.Tags {
b.WriteString(" -t '")
b.WriteString(tag.Key())
b.WriteString(tag[0])
if len(tag) > 1 {
b.WriteString("=")
b.WriteString(tag[1])

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
}

56
nostrfs/asyncfile.go Normal file
View File

@@ -0,0 +1,56 @@
package nostrfs
import (
"context"
"sync/atomic"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"fiatjaf.com/nostr"
)
type AsyncFile struct {
fs.Inode
ctx context.Context
fetched atomic.Bool
data []byte
ts nostr.Timestamp
load func() ([]byte, nostr.Timestamp)
}
var (
_ = (fs.NodeOpener)((*AsyncFile)(nil))
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
)
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
if af.fetched.CompareAndSwap(false, true) {
af.data, af.ts = af.load()
}
out.Size = uint64(len(af.data))
out.Mtime = uint64(af.ts)
return fs.OK
}
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
if af.fetched.CompareAndSwap(false, true) {
af.data, af.ts = af.load()
}
return nil, fuse.FOPEN_KEEP_CACHE, 0
}
func (af *AsyncFile) Read(
ctx context.Context,
f fs.FileHandle,
dest []byte,
off int64,
) (fuse.ReadResult, syscall.Errno) {
end := int(off) + len(dest)
if end > len(af.data) {
end = len(af.data)
}
return fuse.ReadResultData(af.data[off:end]), 0
}

View File

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

408
nostrfs/entitydir.go Normal file
View File

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

241
nostrfs/eventdir.go Normal file
View File

@@ -0,0 +1,241 @@
package nostrfs
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip10"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip22"
"fiatjaf.com/nostr/nip27"
"fiatjaf.com/nostr/nip73"
"fiatjaf.com/nostr/nip92"
sdk "fiatjaf.com/nostr/sdk"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type EventDir struct {
fs.Inode
ctx context.Context
wd string
evt *nostr.Event
}
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mtime = uint64(e.evt.CreatedAt)
return fs.OK
}
func (r *NostrRoot) FetchAndCreateEventDir(
parent fs.InodeEmbedder,
pointer nostr.EventPointer,
) (*fs.Inode, error) {
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
return r.CreateEventDir(parent, event), nil
}
func (r *NostrRoot) CreateEventDir(
parent fs.InodeEmbedder,
event *nostr.Event,
) *fs.Inode {
h := parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
)
h.AddChild("@author", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
eventj, _ := json.MarshalIndent(event, "", " ")
h.AddChild("event.json", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: eventj,
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(len(event.Content)),
},
},
fs.StableAttr{},
), true)
h.AddChild("id", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.ID.Hex()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(64),
},
},
fs.StableAttr{},
), true)
h.AddChild("content.txt", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.Content),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(len(event.Content)),
},
},
fs.StableAttr{},
), true)
var refsdir *fs.Inode
i := 0
for ref := range nip27.Parse(event.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
i++
if refsdir == nil {
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("references", refsdir, true)
}
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
var imagesdir *fs.Inode
images := nip92.ParseTags(event.Tags)
for _, imeta := range images {
if imeta.URL == "" {
continue
}
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("images", imagesdir, true)
}
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
r.ctx,
&AsyncFile{
ctx: r.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
if err != nil {
return nil, 0
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
return nil, 0
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, 0
}
w := &bytes.Buffer{}
io.Copy(w, resp.Body)
return w.Bytes(), 0
},
},
fs.StableAttr{},
), true)
}
if event.Kind == 1 {
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@root", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
} else if event.Kind == 1111 {
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@root", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
fs.StableAttr{},
), true)
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
}
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
fs.StableAttr{},
), true)
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
}
}
return h
}

16
nostrfs/helpers.go Normal file
View File

@@ -0,0 +1,16 @@
package nostrfs
import (
"fiatjaf.com/nostr"
)
func kindToExtension(kind nostr.Kind) string {
switch kind {
case 30023:
return "md"
case 30818:
return "adoc"
default:
return "txt"
}
}

261
nostrfs/npubdir.go Normal file
View File

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

130
nostrfs/root.go Normal file
View File

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

267
nostrfs/viewdir.go Normal file
View File

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

93
nostrfs/writeablefile.go Normal file
View File

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

97
outbox.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"fiatjaf.com/nostr/sdk"
"fiatjaf.com/nostr/sdk/hints/bbolth"
"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 != "" {
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
}
if hintsFilePath != "" {
if _, err := os.Stat(hintsFilePath); err == nil {
hintsFileExists = true
} else if !os.IsNotExist(err) {
return err
}
}
if hintsFileExists && hintsFilePath != "" {
hintsdb, err := bbolth.NewBoltHints(hintsFilePath)
if err == nil {
sys.Hints = hintsdb
}
}
return nil
}
var outbox = &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 := bbolth.NewBoltHints(hintsFilePath)
if err != nil {
return fmt.Errorf("failed to create bolt 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 := parsePubKey(c.Args().First())
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", c.Args().First(), err)
}
for _, relay := range sys.FetchOutboxRelays(ctx, pk, 6) {
stdout(relay)
}
return nil
},
},
},
}

View File

@@ -1,73 +0,0 @@
package main
import (
"context"
"math"
"slices"
"time"
"github.com/nbd-wtf/go-nostr"
)
func paginateWithPoolAndParams(pool *nostr.SimplePool, interval time.Duration, globalLimit uint64) func(ctx context.Context, urls []string, filters nostr.Filters) chan nostr.IncomingEvent {
return func(ctx context.Context, urls []string, filters nostr.Filters) chan nostr.IncomingEvent {
// filters will always be just one
filter := filters[0]
nextUntil := nostr.Now()
if filter.Until != nil {
nextUntil = *filter.Until
}
if globalLimit == 0 {
globalLimit = uint64(filter.Limit)
if globalLimit == 0 && !filter.LimitZero {
globalLimit = math.MaxUint64
}
}
var globalCount uint64 = 0
globalCh := make(chan nostr.IncomingEvent)
repeatedCache := make([]string, 0, 300)
nextRepeatedCache := make([]string, 0, 300)
go func() {
defer close(globalCh)
for {
filter.Until = &nextUntil
time.Sleep(interval)
keepGoing := false
for evt := range pool.SubManyEose(ctx, urls, nostr.Filters{filter}) {
if slices.Contains(repeatedCache, evt.ID) {
continue
}
keepGoing = true // if we get one that isn't repeated, then keep trying to get more
nextRepeatedCache = append(nextRepeatedCache, evt.ID)
globalCh <- evt
globalCount++
if globalCount >= globalLimit {
return
}
if evt.CreatedAt < *filter.Until {
nextUntil = evt.CreatedAt
}
}
if !keepGoing {
return
}
repeatedCache = nextRepeatedCache
nextRepeatedCache = nextRepeatedCache[:0]
}
}()
return globalCh
}
}

181
publish.go Normal file
View File

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

200
relay.go
View File

@@ -1,27 +1,19 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip11"
"github.com/nbd-wtf/go-nostr/nip86"
"fiatjaf.com/nostr/nip11"
"github.com/urfave/cli/v3"
)
var relay = &cli.Command{
Name: "relay",
Usage: "gets the relay information document for the given relay, as JSON",
Description: `example:
nak relay nostr.wine`,
Description: `
nak relay nostr.wine
`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
@@ -41,186 +33,4 @@ var relay = &cli.Command{
}
return nil
},
Commands: (func() []*cli.Command {
commands := make([]*cli.Command, 0, 12)
for _, def := range []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},
} {
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,
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex",
DefaultText: "the key '1'",
Value: "0000000000000000000000000000000000000000000000000000000000000001",
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
},
&cli.StringFlag{
Name: "connect",
Usage: "sign event using NIP-46, expects a bunker://... URL",
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to when communicating with the bunker given on --connect",
DefaultText: "a random key",
},
)
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),
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
}
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(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)
authEvent := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", httpUrl},
{"method", "POST"},
{"payload", hex.EncodeToString(payloadHash[:])},
},
}
if bunker != nil {
if err := bunker.SignEvent(ctx, &authEvent); err != nil {
return fmt.Errorf("failed to sign with bunker: %w", err)
}
} else if err := authEvent.Sign(sec); err != nil {
return fmt.Errorf("error signing with provided key: %w", err)
}
evtj, _ := json.Marshal(authEvent)
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
},
Flags: flags,
}
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)
}
}

427
req.go
View File

@@ -1,15 +1,25 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"math"
"os"
"slices"
"strings"
"sync"
"github.com/fiatjaf/cli/v3"
"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/nbd-wtf/go-nostr"
"github.com/urfave/cli/v3"
"golang.org/x/sync/errgroup"
)
const (
@@ -20,7 +30,7 @@ const (
var req = &cli.Command{
Name: "req",
Usage: "generates encoded REQ messages and optionally use them to talk to relays",
Description: `outputs a NIP-01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
Description: `outputs a nip01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
example:
nak req -k 1 -l 15 wss://nostr.wine wss://nostr-pub.wellorder.net
@@ -31,97 +41,109 @@ it can also take a filter from stdin, optionally modify it with flags and send i
example:
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
DisableSliceFlagSeparator: true,
Flags: append(reqFilterFlags,
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
DefaultText: "false",
},
&cli.DurationFlag{
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",
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform NIP-42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a NIP-42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the AUTH challenge, as hex or nsec",
DefaultText: "the key '1'",
Value: "0000000000000000000000000000000000000000000000000000000000000001",
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the AUTH challenge",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect",
Usage: "sign AUTH using NIP-46, expects a bunker://... URL",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to when communicating with the bunker given on --connect",
DefaultText: "a random key",
Category: CATEGORY_SIGNER,
},
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "outbox",
Usage: "use outbox relays from specified public keys",
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",
DefaultText: "false",
},
&cli.DurationFlag{
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",
},
&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 \"REQ\"",
Category: CATEGORY_SIGNER,
},
)...,
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
var pool *nostr.SimplePool
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")
}
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
var relays []*nostr.Relay
pool, relays = connectToAllRelays(ctx, relayUrls, c.Bool("force-pre-auth"), nostr.WithAuthHandler(func(evt *nostr.Event) error {
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth not authorized")
}
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
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
}
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)
},
})
var pk string
if bunker != nil {
pk, err = bunker.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get public key from bunker: %w", err)
}
} else {
pk, _ = nostr.GetPublicKey(sec)
}
log("performing auth as %s... ", pk)
if bunker != nil {
return bunker.SignEvent(ctx, evt)
} else {
return evt.Sign(sec)
}
}))
// stop here already if all connections failed
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -130,15 +152,10 @@ example:
for i, relay := range relays {
relayUrls[i] = relay.URL
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
}
for stdinFilter := range getStdinLinesOrBlank() {
// 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 {
@@ -151,16 +168,164 @@ example:
return err
}
if len(relayUrls) > 0 {
fn := pool.SubManyEose
if c.Bool("paginate") {
fn = paginateWithPoolAndParams(pool, c.Duration("paginate-interval"), c.Uint("paginate-global-limit"))
} else if c.Bool("stream") {
fn = pool.SubMany
}
if len(relayUrls) > 0 || c.Bool("outbox") {
if negentropy {
store := &slicestore.SliceStore{}
store.Init()
for ie := range fn(ctx, relayUrls, nostr.Filters{filter}) {
stdout(ie.Event)
if syncFile := c.String("only-missing"); syncFile != "" {
file, err := os.Open(syncFile)
if err != nil {
return fmt.Errorf("failed to open sync file: %w", err)
}
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
}
if err := store.SaveEvent(evt); err != nil || err == eventstore.ErrDupEvent {
continue
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read sync file: %w", err)
}
}
target := PrintingQuerierPublisher{
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
}
var source nostr.Querier = nil
if c.IsSet("only-missing") {
source = target
}
handle := nip77.SyncEventsFromIDs
if c.Bool("ids-only") {
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
handle = func(ctx context.Context, dir nip77.Direction) {
for id := range dir.Items {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
stdout(id.Hex())
}
}
}
for _, url := range relayUrls {
err := nip77.NegentropySync(ctx, url, filter, source, target, handle)
if err != nil {
log("negentropy sync from %s failed: %s", url, err)
}
}
} else {
var results chan nostr.RelayEvent
var closeds chan nostr.RelayClosed
opts := nostr.SubscriptionOptions{
Label: "nak-req",
}
if c.Bool("paginate") {
paginator := sys.Pool.PaginatorWithInterval(c.Duration("paginate-interval"))
results = paginator(ctx, relayUrls, filter, opts)
} else if c.Bool("outbox") {
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
// hardcoded relays, if any
for _, relayUrl := range relayUrls {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: relayUrl,
})
}
// relays for each pubkey
errg := errgroup.Group{}
errg.SetLimit(16)
mu := sync.Mutex{}
for _, pubkey := range filter.Authors {
errg.Go(func() error {
n := int(c.Uint("outbox-relays-per-pubkey"))
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already hardcoded, 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 c.Bool("stream") {
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
} else {
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
}
} else {
if c.Bool("stream") {
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
} else {
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
}
}
readevents:
for {
select {
case ie, ok := <-results:
if !ok {
break readevents
}
stdout(ie.Event)
case closed := <-closeds:
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
}
}
}
} else {
// no relays given, will just print the filter
@@ -168,7 +333,7 @@ example:
if c.Bool("bare") {
result = filter.String()
} else {
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: nostr.Filters{filter}})
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
result = string(j)
}
@@ -182,16 +347,16 @@ example:
}
var reqFilterFlags = []cli.Flag{
&cli.StringSliceFlag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
&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{
@@ -241,41 +406,41 @@ var reqFilterFlags = []cli.Flag{
},
&cli.StringFlag{
Name: "search",
Usage: "a NIP-50 search query, use it only with relays that explicitly support it",
Usage: "a nip50 search query, use it only with relays that explicitly support it",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
}
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
if authors := c.StringSlice("author"); len(authors) > 0 {
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = append(filter.Authors, authors...)
}
if ids := c.StringSlice("id"); len(ids) > 0 {
if ids := getIDSlice(c, "id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
for _, kind64 := range c.IntSlice("kind") {
filter.Kinds = append(filter.Kinds, int(kind64))
filter.Kinds = append(filter.Kinds, nostr.Kind(kind64))
}
if search := c.String("search"); search != "" {
filter.Search = search
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.Split(tagFlag, "=")
if len(spl) == 2 && len(spl[0]) == 1 {
tags = append(tags, spl)
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", dtag})
tags = append(tags, []string{"d", decodeTagValue(dtag)})
}
if len(tags) > 0 && filter.Tags == nil {
@@ -290,13 +455,10 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
}
if c.IsSet("since") {
nts := getNaturalDate(c, "since")
filter.Since = &nts
filter.Since = getNaturalDate(c, "since")
}
if c.IsSet("until") {
nts := getNaturalDate(c, "until")
filter.Until = &nts
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Uint("limit"); limit != 0 {
@@ -307,3 +469,18 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
return nil
}
type PrintingQuerierPublisher struct {
nostr.QuerierPublisher
}
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
stdout(evt)
return nil
} else if err == eventstore.ErrDupEvent {
return nil
} else {
return err
}
}

195
serve.go
View File

@@ -2,19 +2,25 @@ package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"io"
"net/url"
"os"
"path/filepath"
"sync/atomic"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/khatru"
"fiatjaf.com/nostr/khatru/blossom"
"fiatjaf.com/nostr/khatru/grasp"
"github.com/bep/debounce"
"github.com/fatih/color"
"github.com/fiatjaf/cli/v3"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
"github.com/puzpuzpuz/xsync/v3"
"github.com/urfave/cli/v3"
)
var serve = &cli.Command{
@@ -37,9 +43,30 @@ 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{MaxLimit: math.MaxInt}
db := &slicestore.SliceStore{}
var blobStore *xsync.MapOf[string, []byte]
var repoDir string
var scanner *bufio.Scanner
if path := c.String("events"); path != "" {
@@ -60,16 +87,23 @@ var serve = &cli.Command{
if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
return fmt.Errorf("invalid event received at line %d: %s (`%s`)", i, err, scanner.Text())
}
db.SaveEvent(ctx, &evt)
db.SaveEvent(evt)
i++
}
}
rl := khatru.NewRelay()
rl.QueryEvents = append(rl.QueryEvents, db.QueryEvents)
rl.CountEvents = append(rl.CountEvents, db.CountEvents)
rl.DeleteEvent = append(rl.DeleteEvent, db.DeleteEvent)
rl.StoreEvent = append(rl.StoreEvent, db.SaveEvent)
rl.Info.Name = "nak serve"
rl.Info.Description = "a local relay for testing, debugging and development."
rl.Info.Software = "https://github.com/fiatjaf/nak"
rl.Info.Version = version
rl.UseEventstore(db, 500)
if c.Bool("negentropy") {
rl.Negentropy = true
}
started := make(chan bool)
exited := make(chan error)
@@ -77,49 +111,146 @@ var serve = &cli.Command{
hostname := c.String("hostname")
port := int(c.Uint("port"))
var printStatus func()
if c.Bool("blossom") {
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
bs.Store = blossom.NewMemoryBlobIndex()
blobStore = xsync.NewMapOf[string, []byte]()
bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
blobStore.Store(sha256+ext, body)
log(" got %s %s\n", color.GreenString("blob stored"), sha256+ext)
printStatus()
return nil
}
bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) {
if body, ok := blobStore.Load(sha256 + ext); ok {
log(" got %s %s\n", color.BlueString("blob downloaded"), sha256+ext)
printStatus()
return bytes.NewReader(body), nil, nil
}
return nil, nil, nil
}
bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error {
blobStore.Delete(sha256 + ext)
log(" got %s %s\n", color.RedString("blob deleted"), sha256+ext)
printStatus()
return nil
}
}
if c.Bool("grasp") {
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, ""
}
}
go func() {
err := rl.Start(hostname, port, started)
exited <- err
}()
bold := color.New(color.Bold).Sprintf
italic := color.New(color.Italic).Sprint
var printStatus func()
// relay logging
rl.RejectFilter = append(rl.RejectFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiYellowString("request"), italic(filter))
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
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, ""
})
rl.RejectCountFilter = append(rl.RejectCountFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiCyanString("count request"), italic(filter))
}
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
printStatus()
return false, ""
})
rl.RejectEvent = append(rl.RejectEvent, func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
log(" got %s %v\n", color.BlueString("event"), italic(event))
}
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
printStatus()
return false, ""
})
}
totalConnections := atomic.Int32{}
rl.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
d := debounce.New(time.Second * 2)
printStatus = func() {
d(func() {
totalEvents := 0
ch, _ := db.QueryEvents(ctx, nostr.Filter{})
for range ch {
totalEvents++
totalEvents, err := db.CountEvents(nostr.Filter{})
if err != nil {
log("failed to count: %s\n", err)
}
subs := rl.GetListeningFilters()
log(" %s events stored: %s, subscriptions opened: %s\n", color.HiMagentaString("•"), color.HiMagentaString("%d", totalEvents), color.HiMagentaString("%d", len(subs)))
blossomMsg := ""
if c.Bool("blossom") {
blobsStored := blobStore.Size()
blossomMsg = fmt.Sprintf("blobs: %s, ",
color.HiMagentaString("%d", blobsStored),
)
}
graspMsg := ""
if c.Bool("grasp") {
gitAnnounced := 0
gitStored := 0
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
gitAnnounced++
identifier := evt.Tags.GetD()
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
gitStored++
}
}
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
color.HiMagentaString("%d", gitAnnounced),
color.HiMagentaString("%d", gitStored),
)
}
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
color.HiMagentaString("•"),
color.HiMagentaString("%d", totalEvents),
blossomMsg,
graspMsg,
color.HiMagentaString("%d", totalConnections.Load()),
color.HiMagentaString("%d", len(subs)),
)
})
}
<-started
log("%s relay running at %s\n", color.HiRedString(">"), bold("ws://%s:%d", hostname, port))
log("%s relay running at %s", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
if c.Bool("grasp") {
log(" (grasp repos at %s)", repoDir)
}
log("\n")
return <-exited
},

464
sync.go Normal file
View File

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

View File

@@ -2,39 +2,48 @@ package main
import (
"context"
"encoding/json"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"fiatjaf.com/nostr"
"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("<>: invalid event.\n", evt.ID.Hex())
continue
}
if ok, err := evt.CheckSignature(); !ok {
ctx = lineProcessingError(ctx, "invalid signature: %s", err)
if evt.GetID() != evt.ID {
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
logverbose("%s: invalid id.\n", evt.ID.Hex())
continue
}
if !evt.VerifySignature() {
ctx = lineProcessingError(ctx, "invalid signature")
logverbose("%s: invalid signature.\n", evt.ID.Hex())
continue
}
logverbose("%s: valid.\n", evt.ID.Hex())
}
exitIfLineProcessingError(ctx)

542
wallet.go Normal file
View File

@@ -0,0 +1,542 @@
package main
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip60"
"fiatjaf.com/nostr/nip61"
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(), error) {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return nil, nil, err
}
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return nil, nil, err
}
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")
}
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)
}
}
w.PublishUpdate = func(event nostr.Event, deleted, received, change *nip60.Token, isHistory bool) {
desc := "wallet"
if received != nil {
mint, _ := strings.CutPrefix(received.Mint, "https://")
desc = fmt.Sprintf("received from %s with %d proofs totalling %d",
mint, len(received.Proofs), received.Proofs.Amount())
} else if change != nil {
mint, _ := strings.CutPrefix(change.Mint, "https://")
desc = fmt.Sprintf("change from %s with %d proofs totalling %d",
mint, len(change.Proofs), change.Proofs.Amount())
} else if deleted != nil {
mint, _ := strings.CutPrefix(deleted.Mint, "https://")
desc = fmt.Sprintf("deleting a used token from %s with %d proofs totalling %d",
mint, len(deleted.Proofs), deleted.Proofs.Amount())
} else if isHistory {
desc = "history entry"
}
log("- saving kind:%d event (%s)... ", event.Kind, desc)
first := true
for res := range sys.Pool.PublishMany(ctx, relays, event) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else {
log("%s: ok", colors.successf(cleanUrl))
}
}
log("\n")
}
<-w.Stable
return w, func() {
w.Close()
}, nil
}
var wallet = &cli.Command{
Name: "wallet",
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: 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
},
Commands: []*cli.Command{
{
Name: "mints",
Usage: "lists, adds or remove default mints from the wallet",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
for _, url := range w.Mints {
stdout(strings.Split(url, "://")[1])
}
closew()
return nil
},
Commands: []*cli.Command{
{
Name: "add",
DisableSliceFlagSeparator: true,
ArgsUsage: "<mint>...",
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
if err := w.AddMint(ctx, c.Args().Slice()...); err != nil {
return err
}
closew()
return nil
},
},
{
Name: "remove",
DisableSliceFlagSeparator: true,
ArgsUsage: "<mint>...",
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
if err := w.RemoveMint(ctx, c.Args().Slice()...); err != nil {
return err
}
closew()
return nil
},
},
},
},
{
Name: "tokens",
Usage: "lists existing tokens with their mints and aggregated amounts",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
for _, token := range w.Tokens {
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
}
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())
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
}
}
closew()
return nil
},
},
},
},
{
Name: "receive",
Usage: "takes a cashu token string as an argument and adds it to the wallet",
ArgsUsage: "<token>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "mint",
Usage: "mint to swap the token into",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) != 1 {
return fmt.Errorf("must be called as `nak wallet receive <token>")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
proofs, mint, err := nip60.GetProofsAndMint(args[0])
if err != nil {
return err
}
if err := w.Receive(ctx, proofs, mint, nip60.ReceiveOptions{
IntoMint: c.StringSlice("mint"),
}); err != nil {
return err
}
closew()
return nil
},
},
{
Name: "send",
Usage: "prints a cashu token with the given amount for sending to someone else",
ArgsUsage: "<amount>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "mint",
Usage: "send from a specific mint",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) != 1 {
return fmt.Errorf("must be called as `nak wallet send <amount>")
}
amount, err := strconv.ParseUint(args[0], 10, 64)
if err != nil {
return fmt.Errorf("amount '%s' is invalid", args[0])
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
var sourceMint string
if mint := c.String("mint"); mint != "" {
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
}
proofs, mint, err := w.SendInternal(ctx, amount, nip60.SendOptions{
SpecificSourceMint: sourceMint,
})
if err != nil {
return err
}
stdout(nip60.MakeTokenString(proofs, mint))
closew()
return nil
},
},
{
Name: "pay",
Usage: "pays a bolt11 lightning invoice and outputs the preimage",
ArgsUsage: "<invoice>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "mint",
Usage: "pay from a specific mint",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) != 1 {
return fmt.Errorf("must be called as `nak wallet pay <invoice>")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
var sourceMint string
if mint := c.String("mint"); mint != "" {
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
}
preimage, err := w.PayBolt11(ctx, args[0], nip60.PayOptions{
FromMint: sourceMint,
})
if err != nil {
return err
}
stdout(preimage)
closew()
return nil
},
},
{
Name: "nutzap",
Usage: "sends a nip61 nutzap to one or more Nostr profiles and/or events",
ArgsUsage: "<amount> <target>",
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "mint",
Usage: "send from a specific mint",
},
&cli.StringFlag{
Name: "message",
Usage: "attach a message to the nutzap",
},
},
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>...")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
amount, err := strconv.ParseInt(c.Args().First(), 10, 64)
if err != nil {
return fmt.Errorf("invalid amount '%s': %w", c.Args().First(), err)
}
target := c.String("target")
var pm sdk.ProfileMetadata
var evt *nostr.Event
var eventId nostr.ID
if strings.HasPrefix(target, "nevent1") {
evt, _, err = sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return err
}
eventId = evt.ID
pm = sys.FetchProfileMetadata(ctx, evt.PubKey)
} else {
pm, err = sys.FetchProfileFromInput(ctx, target)
if err != nil {
return err
}
}
log("sending %d sat to '%s' (%s)", amount, pm.ShortName(), pm.Npub())
var sourceMint string
if mint := c.String("mint"); mint != "" {
sourceMint = "http" + nostr.NormalizeURL(mint)[2:]
}
kr, _, _ := gatherKeyerFromArguments(ctx, c)
results, err := nip61.SendNutzap(
ctx,
kr,
w,
sys.Pool,
uint64(amount),
pm.PubKey,
sys.FetchWriteRelays(ctx, pm.PubKey),
nip61.NutzapOptions{
Message: c.String("message"),
SendToRelays: sys.FetchInboxRelays(ctx, pm.PubKey, 3),
EventID: eventId,
SpecificSourceMint: sourceMint,
},
)
if err != nil {
return err
}
log("- publishing nutzap... ")
first := true
for res := range results {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else {
log("%s: ok", colors.successf(cleanUrl))
}
}
closew()
return nil
},
Commands: []*cli.Command{
{
Name: "setup",
Usage: "setup your wallet private key and kind:10019 event for receiving nutzaps",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "mint",
Usage: "mints to receive nutzaps in",
},
&cli.StringFlag{
Name: "private-key",
Usage: "private key used for receiving nutzaps",
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "forces replacement of private-key",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
if w.PrivateKey == nil {
if sk := c.String("private-key"); sk != "" {
if err := w.SetPrivateKey(ctx, sk); err != nil {
return err
}
} else {
return fmt.Errorf("missing --private-key")
}
} else if sk := c.String("private-key"); sk != "" && !c.Bool("force") {
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
}
kr, _, _ := gatherKeyerFromArguments(ctx, c)
pk, _ := kr.GetPublicKey(ctx)
relays := sys.FetchWriteRelays(ctx, pk)
info := nip61.Info{}
ie := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10019},
Authors: []nostr.PubKey{pk},
Limit: 1,
}, nostr.SubscriptionOptions{})
if ie != nil {
info.ParseEvent(ie.Event)
}
if mints := c.StringSlice("mints"); len(mints) == 0 && len(info.Mints) == 0 {
info.Mints = w.Mints
}
if len(info.Mints) == 0 {
return fmt.Errorf("missing --mint")
}
evt := nostr.Event{}
if err := info.ToEvent(ctx, kr, &evt); err != nil {
return err
}
stdout(evt)
log("- saving kind:10019 event... ")
first := true
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else {
log("%s: ok", colors.successf(cleanUrl))
}
}
closew()
return nil
},
},
},
},
},
}

7
zapstore.yaml Normal file
View File

@@ -0,0 +1,7 @@
repository: https://github.com/fiatjaf/nak
assets:
- nak-v\d+\.\d+\.\d+-darwin-arm64
- nak-v\d+\.\d+\.\d+-linux-amd64
- nak-v\d+\.\d+\.\d+-linux-arm64
remote_metadata:
- github