Compare commits

...

567 Commits

Author SHA1 Message Date
fiatjaf
1403c7d2cd fetch: use more outbox relays. 2026-07-21 20:42:52 -03:00
fiatjaf
6855722ba5 make the relay connection timeout configurable. 2026-07-21 20:41:45 -03:00
fiatjaf
aac995714b timeout relay connections at 2s. 2026-07-21 18:51:41 -03:00
fiatjaf
a9df9c6a21 groups: nip29 parenting. 2026-07-20 17:05:36 -03:00
fiatjaf
57be7627d4 git: allow pushing without updating the repository announcement. 2026-07-17 22:51:21 -03:00
fiatjaf
5b7f3eb4db git: 'patch pull' similar to 'pr pull'. 2026-07-16 17:08:02 -03:00
fiatjaf
6d4f4c03ec git: eliminate maintainer and web setup. 2026-07-16 15:36:59 -03:00
fiatjaf
eb9afd0b54 pr: merge. 2026-07-16 15:32:42 -03:00
fiatjaf
9c35de2b02 nak git pr 2026-07-16 15:23:30 -03:00
fiatjaf
b65683886b nak key public prints our key if given by --sec. 2026-07-15 16:30:58 -03:00
fiatjaf
e36d546548 nip: eliminate wrong prints. 2026-07-15 12:55:54 -03:00
mattn
b6fa2e4f64 Merge pull request #185 from mattn/fix/dekey-corrupted-error-message
gift, dekey: fix key-mismatch error wrapping a nil err with %w
2026-07-15 09:27:42 +00:00
Yasuhiro Matsumoto
df2aaefb6d gift, dekey: the key-mismatch error was wrapping a nil err with %w, producing a mangled message. 2026-07-15 18:27:19 +09:00
mattn
3e50a8cba7 Merge pull request #184 from mattn/fix/admin-body-leak
admin: close the response body and don't use it as a format string
2026-07-15 09:27:10 +00:00
Yasuhiro Matsumoto
c525a25e51 admin: close the response body on error paths too, and don't use the response body as a format string. 2026-07-15 18:26:43 +09:00
mattn
1cb730f34d Merge pull request #183 from mattn/fix/podcast-format-strings
podcast: pass the title into the format string
2026-07-15 09:26:33 +00:00
Yasuhiro Matsumoto
1d6c352e92 podcast: pass the title into the format string, it was printing '%!s(MISSING)'. 2026-07-15 18:26:11 +09:00
mattn
d3347ec2b0 Merge pull request #182 from mattn/fix/mcp-missing-separator
mcp: separate the pubkey and content lines in read_events_from_relay output
2026-07-15 09:26:00 +00:00
Yasuhiro Matsumoto
f288a59b3f mcp: separate the pubkey and content lines in read_events_from_relay output, they were fused together. 2026-07-15 18:21:16 +09:00
mattn
549903dff2 Merge pull request #181 from mattn/fix/podcast-author-no-role
podcast: don't drop authors whose p tag has no role element
2026-07-15 09:21:08 +00:00
Yasuhiro Matsumoto
2298e37cdf podcast: don't drop authors whose p tag has no role element. 2026-07-15 18:12:13 +09:00
mattn
f10555037e Merge pull request #180 from mattn/fix/git-download-stdout
git: don't write a literal '-' file after download printed to stdout
2026-07-15 09:12:05 +00:00
Yasuhiro Matsumoto
e493fcdb03 git: don't fall through to writing a literal '-' file after download already printed the object to stdout. 2026-07-15 18:01:15 +09:00
mattn
03432424d2 Merge pull request #179 from mattn/fix/git-download-symref
git: fix dead symref resolution branch in download
2026-07-15 09:01:06 +00:00
Yasuhiro Matsumoto
ade6935c91 git: fix dead symref resolution branch in download, it was testing the always-empty ch instead of the symref lookup. 2026-07-15 17:52:15 +09:00
mattn
a73979c570 Merge pull request #178 from mattn/fix/relay-exit-code
relay: exit with an error code when fetching the information document fails
2026-07-15 08:52:06 +00:00
Yasuhiro Matsumoto
5874bbe588 relay: exit with an error code when fetching the information document fails, like the other line-processing commands. 2026-07-15 17:44:15 +09:00
mattn
59bcd97617 Merge pull request #177 from mattn/fix/mcp-search-profile-limit
mcp: apply a default limit of 10 to search_profile
2026-07-15 08:44:04 +00:00
Yasuhiro Matsumoto
b420b81fd5 mcp: default search_profile limit to 10, with the flag omitted it was 0 and the loop stopped after the first result. 2026-07-15 17:34:23 +09:00
mattn
461e9c612e Merge pull request #176 from mattn/fix/lmdb-hints-unused
lmdb: actually assign the hints db to sys.Hints
2026-07-15 08:34:15 +00:00
Yasuhiro Matsumoto
49713588bd lmdb: actually assign the lmdb hints db to sys.Hints, it was created and discarded so hints never persisted. 2026-07-15 17:26:11 +09:00
mattn
80ce8059ea Merge pull request #175 from mattn/fix/git-init-interactive-discard
git: init --interactive was discarding the prompted identifier and owner
2026-07-15 08:13:28 +00:00
Yasuhiro Matsumoto
631c57b4bf git: init --interactive was discarding the identifier and owner entered at the prompt when nip34.json already existed. 2026-07-15 16:35:20 +09:00
mattn
0d3574c9fe Merge pull request #174 from mattn/fix/filter-merge-kind
filter: merge kind from the base event too
2026-07-15 07:26:13 +00:00
Yasuhiro Matsumoto
e3aba47046 filter: merge kind from the base event too, otherwise kind filters match against 0. 2026-07-15 15:59:55 +09:00
mattn
b46e9c7dd5 Merge pull request #173 from mattn/fix/kind-overflow
helpers: reject kind numbers above 65535
2026-07-15 06:50:43 +00:00
Yasuhiro Matsumoto
6bd793bea5 helpers: reject kind numbers above 65535 instead of silently truncating them to uint16. 2026-07-15 15:34:29 +09:00
mattn
f96b9d458e Merge pull request #172 from mattn/fix/parse-event-id-note1
helpers: fix parseEventID rejecting note1 codes
2026-07-15 06:24:52 +00:00
Yasuhiro Matsumoto
d8e951e881 helpers: fix parseEventID rejecting note1 codes, nip19.Decode returns an EventPointer for those. 2026-07-15 15:17:24 +09:00
mattn
51f79e1d3c Merge pull request #171 from mattn/fix/blossom-download-stdout
blossom: write downloaded blob bytes raw to stdout
2026-07-15 06:10:50 +00:00
Yasuhiro Matsumoto
49f2839fb1 blossom: write downloaded blob bytes raw to stdout, Fprintln was printing them as a decimal byte array. 2026-07-15 15:03:44 +09:00
mattn
4911811e75 Merge pull request #170 from mattn/fix/key-validate-npub-panic
key: fix panic in validate with npub input
2026-07-15 06:02:36 +00:00
Yasuhiro Matsumoto
f1de27c88c key: fix panic in validate with npub input, nip19.Decode returns a nostr.PubKey not a bare [32]byte. 2026-07-15 15:01:13 +09:00
mattn
514fed0b79 Merge pull request #169 from mattn/fix/podcast-note1-panic
podcast: fix panic on note1 targets
2026-07-15 06:00:05 +00:00
Yasuhiro Matsumoto
c1ebb829b9 podcast: fix panic on note1 targets, nip19.Decode returns an EventPointer for those. 2026-07-15 14:53:38 +09:00
mattn
d4d808e9b5 Merge pull request #168 from mattn/fix/count-nil-relay
count: don't ignore EnsureRelay errors
2026-07-15 05:46:49 +00:00
Yasuhiro Matsumoto
c9f7632571 count: don't ignore EnsureRelay errors, relay can be nil and Count would panic. 2026-07-15 14:37:44 +09:00
mattn
9101a3a543 Merge pull request #167 from mattn/fix/grasp-host-panic
git: don't panic in graspServerHost on empty or unparseable URLs
2026-07-15 05:28:42 +00:00
Yasuhiro Matsumoto
d419b35b66 git: don't panic in graspServerHost when the server URL is empty or unparseable. 2026-07-15 14:22:31 +09:00
mattn
fd8fdad26a Merge pull request #166 from mattn/fix/git-status-nil-state
git: fix nil dereference in status when the repository state isn't published
2026-07-15 05:14:50 +00:00
Yasuhiro Matsumoto
232ef8d02e git: fix nil dereference in status when the repository state isn't published, and don't panic slicing short/empty commit hashes. 2026-07-15 14:01:42 +09:00
mattn
09539a317f Merge pull request #165 from mattn/fix/git-pull-nil-state
git: fix nil dereference in pull when the repository has no published state
2026-07-15 04:52:55 +00:00
Yasuhiro Matsumoto
bcc4f42cd7 git: fix nil dereference in pull when the repository has no published state event. 2026-07-15 13:44:39 +09:00
mattn
23bc1d3aa3 Merge pull request #164 from mattn/fix/key-decrypt-stdin
key: fix decrypt never reading the ncryptsec from stdin
2026-07-15 04:29:43 +00:00
Yasuhiro Matsumoto
469c99fb51 key: decrypt with the password as argument never read the ncryptsec from stdin, it was decrypting an empty string. 2026-07-15 13:24:12 +09:00
mattn
f2638ab2de Merge pull request #163 from mattn/fix/verify-validate-args-sentinel
verify, validate: fix the blank-stdin sentinel check
2026-07-15 04:19:08 +00:00
Yasuhiro Matsumoto
dd0b25816a verify, validate: fix the blank-stdin sentinel check, getJsonsOrBlank yields '{}' not '', so the argument forms never worked. 2026-07-15 13:13:30 +09:00
mattn
4fa512edf3 Merge pull request #162 from mattn/fix/event-outbox-relay-leak
event: don't leak --outbox relays across stdin events
2026-07-15 04:08:23 +00:00
Yasuhiro Matsumoto
0055037943 event: don't let --outbox relays accumulated for one stdin event leak into the next ones. 2026-07-15 13:02:50 +09:00
mattn
ab32de2fb3 Merge pull request #161 from mattn/fix/req-outbox-defs-race
req: fix data race on defs in the outbox fan-out
2026-07-15 03:57:39 +00:00
Yasuhiro Matsumoto
6a011ae7d9 req: hold the mutex for the whole defs read-modify-write in the outbox fan-out, reads and the Authors append were racing. 2026-07-15 11:54:34 +09:00
mattn
7c28e75c4b Merge pull request #160 from mattn/fix/connect-relays-race
helpers: guard shared state in connectToAllRelays with a mutex
2026-07-15 02:49:24 +00:00
Yasuhiro Matsumoto
4e0b7ecc08 helpers: guard the shared relays slice and render lines with a mutex in connectToAllRelays, parallel connections were racing and could drop relays. 2026-07-15 11:43:44 +09:00
mattn
586a8920e2 Merge pull request #159 from mattn/fix/sync-neg-err-blocked
sync: wake up Receive when the relay answers NEG-ERR
2026-07-15 02:28:23 +00:00
Yasuhiro Matsumoto
e1e8b11001 sync: wake up Receive when the relay answers NEG-ERR, it was blocked on the messages channel forever since the error was only checked before blocking. 2026-07-15 11:22:39 +09:00
mattn
61b20c6e7a Merge pull request #158 from mattn/fix/sync-deltas-close-on-error
sync: close the Deltas channel on error returns too
2026-07-15 02:17:27 +00:00
Yasuhiro Matsumoto
72e6e735f3 sync: close the Deltas channel on error returns too, otherwise the publisher goroutine hangs forever and the command never exits. 2026-07-15 11:11:45 +09:00
mattn
11abe3afca Merge pull request #157 from mattn/fix/fetch-note1-panic
fetch: fix panic on note1 codes
2026-07-15 02:06:33 +00:00
Yasuhiro Matsumoto
58cfd06eab fetch: fix panic on note1 codes, nip19.Decode returns an EventPointer for those. 2026-07-15 11:00:41 +09:00
mattn
c16a456811 Merge pull request #156 from mattn/fix/blossom-check-hash-truncation
blossom: fix check truncating hashes to 32 chars
2026-07-15 01:55:31 +00:00
Yasuhiro Matsumoto
5c754b1b4f blossom: fix check truncating hashes to 32 chars, a sha256 is 64 hex chars. 2026-07-15 10:49:13 +09:00
mattn
0ad2fa3e65 Merge pull request #155 from mattn/fix/curl-key-flags
curl: fix completely broken command
2026-07-15 01:44:02 +00:00
Yasuhiro Matsumoto
3fa1e2267e curl: fix completely broken command, it lost its key flags when they moved to the root command (which realCurl bypasses) and Run was eating the first flag as the program name. 2026-07-15 10:38:26 +09:00
mattn
97b544fb6e Merge pull request #154 from mattn/fix/bunker-concurrency
bunker: fix concurrency bugs
2026-07-15 01:33:15 +00:00
Yasuhiro Matsumoto
44db7af6c0 bunker: fix concurrency bugs, config.Clients/newSecret/cancelPreviousBunkerInfoPrint were accessed from multiple goroutines without a mutex, and nostrconnect subscriptions forwarded events into the pool-owned channel which panics once the pool closes it. 2026-07-15 10:26:55 +09:00
mattn
34281a9c8e Merge pull request #153 from mattn/fix/nip22-parent-of-external-comment
publish: reference the parent comment with an e tag under external-content (I) threads
2026-07-15 01:25:47 +00:00
Yasuhiro Matsumoto
4f798759dd publish: reference the parent comment with an e tag when commenting under an external-content (I) thread, instead of pointing at the root i again. 2026-07-15 10:24:18 +09:00
mattn
198cbc6d4a Merge pull request #152 from mattn/fix/event-stale-tags
event: reset tags with [:0] instead of clear() between stdin events
2026-07-15 01:16:38 +00:00
Yasuhiro Matsumoto
5d45849595 event: reset tags with [:0] instead of clear(), which keeps the length and leaves phantom empty tags on subsequent stdin events. 2026-07-15 10:15:07 +09:00
mattn
8eb6b7303c Merge pull request #151 from mattn/fix/nip22-empty-relay-hint
publish: don't put the pubkey in the relay-hint position of NIP-22 E/e tags
2026-07-15 01:13:51 +00:00
Yasuhiro Matsumoto
002763669b publish: don't put pubkey in the relay-hint position of NIP-22 E/e tags when there is no relay hint. 2026-07-15 10:12:23 +09:00
mattn
ecb7514716 Merge pull request #150 from mattn/fix/wallet-nutzap-setup
wallet: fix nutzap setup
2026-07-15 01:11:13 +00:00
Yasuhiro Matsumoto
b188d7f803 wallet: fix nutzap setup, --mint was read under the wrong name and never used, the mandatory pubkey tag was never set on the kind:10019, and --force didn't actually replace the private key. 2026-07-15 10:09:46 +09:00
mattn
d79fd1ba59 Merge pull request #149 from mattn/fix/dekey-mismatched-key-kept
dekey: don't keep a decrypted key that doesn't match the announced pubkey
2026-07-15 01:08:34 +00:00
Yasuhiro Matsumoto
e62bf97acc dekey: don't keep a decrypted key that doesn't match the announced pubkey, it could end up being redistributed to other devices. 2026-07-15 10:07:02 +09:00
mattn
c709647823 Merge pull request #148 from mattn/fix/key-combine-crash
key: fix combine crashing with unbounded recursion when no valid pubkeys are given
2026-07-15 01:04:15 +00:00
Yasuhiro Matsumoto
8563178bfd key: combine crashed with unbounded recursion when no valid pubkeys were given, and silently dropped unparseable keys from the result. 2026-07-15 09:40:55 +09:00
fiatjaf
d374fe9bd3 publish: allow kind:1111 comment, force when --comment is used instead of --reply. 2026-07-02 13:32:41 -03:00
fiatjaf
4ea0edc52b two blossom tweaks. 2026-07-02 00:48:43 -03:00
fiatjaf
6087c71072 publish: proper root/reply tags. 2026-07-02 00:18:30 -03:00
fiatjaf
0bcb90e37e publish: also read from arguments, not only stdin. 2026-07-02 00:12:54 -03:00
fiatjaf
98b7a1ae6a admin: color flag is an int now. 2026-06-25 19:00:44 -03:00
fiatjaf
46a809d284 admin: new nip86 "role" commands. 2026-06-25 17:54:52 -03:00
Yasuhiro Matsumoto
cbfc16f354 add --jq-raw flag to print jq string results unquoted, like jq -r 2026-06-22 21:14:26 -03:00
fiatjaf
62fa610a06 add --jq examples to README. 2026-06-21 08:38:15 -03:00
fiatjaf
5afdce2672 profile: flags to print only specific things. 2026-06-21 08:26:55 -03:00
fiatjaf
4fd7400272 date function helpers in --jq 2026-06-21 08:19:33 -03:00
fiatjaf
40a934e3f9 fix conflicts with running bunker connections (client and server apparently) through auth-required relays, clarify "connect-as" and use a special flag for it, let's see.
hopefully fixes https://github.com/fiatjaf/nak/issues/116
2026-06-20 20:30:45 -03:00
fiatjaf
12000232a7 bunker: remove duplicated "--sec" flags that were interfering with everything. 2026-06-20 20:24:18 -03:00
fiatjaf
cc879b6bf6 serve: --auth and --eager-auth flags. 2026-06-20 20:23:39 -03:00
fiatjaf
483bf94ff4 support --jq flag on fetch and event commands. 2026-06-17 11:39:30 -03:00
fiatjaf
814059fcec also use the default key as the bunker client key if nothing else is provided. 2026-06-17 11:11:57 -03:00
fiatjaf
b3e009730c use a default key that is different for each machine. 2026-06-17 10:59:22 -03:00
fiatjaf
7bbff0a3ad fix blossom ipv6 dialing.
fixes https://github.com/fiatjaf/nak/issues/142
2026-06-15 08:31:32 -03:00
fiatjaf
c97e1815b4 nsite: --title 2026-06-15 08:30:22 -03:00
fiatjaf
29113783b3 nip: use the full terminal width. 2026-06-13 09:39:02 -03:00
fiatjaf
59c691f4d5 bring back color. 2026-06-11 18:10:33 -03:00
fiatjaf
0c8e643609 key and auth flags on root command. 2026-06-11 17:49:20 -03:00
fiatjaf
7872c61a4d combineFlags() and authFlags compendium. 2026-06-11 16:23:20 -03:00
fiatjaf
97950d1203 nsite: fine-grained relay control. 2026-06-11 14:16:18 -03:00
fiatjaf
0960ceb6a6 nsite: --tag 2026-06-11 13:59:34 -03:00
fiatjaf
6a95407939 fix getKindSlice() usage. keep nil when no kinds are specified. 2026-06-08 21:41:14 -03:00
fiatjaf
ac988a792d nsite: log identifiers instead of printing. 2026-06-08 21:14:26 -03:00
fiatjaf
fa41c22c12 nak profile example on README. 2026-06-04 16:13:13 -03:00
fiatjaf
c2902bfb5d nak kind, and accept names on --kind flags. 2026-06-04 16:13:13 -03:00
fiatjaf
0f7268c071 preliminary (half-broken) podcast support. 2026-06-04 16:12:48 -03:00
Yasuhiro Matsumoto
f7a00cec10 group chat: handle events synchronously in select, drop goroutine and mutex 2026-06-04 15:05:59 -03:00
alltheseas
52cf03d423 gift unwrap: override rumor pubkey from seal and recompute ID
NIP-17 requires clients verify that the kind:14 pubkey matches
the kind:13 pubkey to prevent sender impersonation. The CLI
gift unwrap path outputs the rumor as-is without setting the
pubkey from the verified seal or recomputing the event ID.

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

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

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

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

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

honestly I don't know why you want to naddr-encode replaceable events, that sounds like an awful idea, but what can I do.
2026-04-18 15:07:27 -03:00
fiatjaf
d3f4548dbd why were we recreating the pool? 2026-04-18 11:13:08 -03:00
fiatjaf
07b791e90a adapt to some nostrlib changes. 2026-04-18 09:19:43 -03:00
fiatjaf
2c3d72b337 nak key validate. 2026-04-10 09:31:51 -03:00
Ren Amamiya
46c68462db git.shakespeare.diy not git.shakespeare.dyi 2026-04-09 11:49:34 -03:00
fiatjaf
0834420d89 replace %w with %s in lineProcessingError(). 2026-04-05 18:24:59 -03:00
fiatjaf
217352ed0b blossom: take multiple --server flags. 2026-04-05 17:12:01 -03:00
fiatjaf
318838b3ff key: a command for expanding partial keys by left-padding with zeroes. 2026-04-03 01:36:08 -03:00
fiatjaf
b811396d8b fetch: exit with error if event is not fetched. 2026-04-02 03:59:45 -03:00
fiatjaf
2bc1cf3417 performReq now uses "read" relays from p/P-tags when no authors are specified in outbox mode. 2026-04-02 03:46:54 -03:00
fiatjaf
65ddf7b821 use appendUnique from nostrlib. 2026-04-02 03:24:05 -03:00
fiatjaf
e19306b124 event: --outbox flag for publishing. 2026-04-02 03:14:19 -03:00
fiatjaf
e059fba487 event/req: accept -h tag and addresses in -a tag. 2026-03-30 12:20:52 -03:00
fiatjaf
00f44958de git: take npub, nip05 or hex for user fields on "init". 2026-03-30 10:03:35 -03:00
fiatjaf
e37ea23a8d groups: stop appending undefined tags to edit-metadata. 2026-03-30 08:56:24 -03:00
fiatjaf
3c4cb533b4 groups: create-group. 2026-03-30 08:56:24 -03:00
fiatjaf
be26dfca3c print "connecting to..." message without the leading scheme. 2026-03-30 08:56:24 -03:00
mattn
26518ec260 Merge pull request #119 from fiatjaf/fix/curl-args-oob
fix: prevent out-of-bounds panic in curl flag parsing
2026-03-30 15:09:52 +09:00
mattn
de599c64f6 Merge pull request #118 from fiatjaf/fix/event-split-safety
fix: use strings.Cut instead of strings.Split to avoid potential panic
2026-03-30 14:10:25 +09:00
Yasuhiro Matsumoto
3d650ee502 fix: use strings.Cut instead of strings.Split to avoid potential panic
strings.Split(url, "://")[1] panics if the URL has no "://" separator.
strings.Cut handles this safely by returning an empty string.
2026-03-30 13:43:29 +09:00
Yasuhiro Matsumoto
67920ccbdd fix: prevent out-of-bounds panic in curl flag parsing
When a flag expecting a value (e.g. --sec) is the last argument,
the code incremented the index without checking if the next
argument exists, causing a potential panic on os.Args access.
2026-03-30 13:43:08 +09:00
fiatjaf
0dbe14aa93 git: 'download' command for downloading individual files. 2026-03-26 17:46:47 -03:00
fiatjaf
ec1721cfe3 count: support CLOSED and AUTH.
fixes https://github.com/fiatjaf/nak/issues/117
2026-03-25 21:42:11 -03:00
fiatjaf
3b318fec54 event: must clear events before reusing. 2026-03-24 22:34:10 -03:00
fiatjaf
82275e4b9e when parsing a nip05 as a pubkey there is no point in falling back to parsing as hex or npub. 2026-03-22 17:17:28 -03:00
fiatjaf
ae98e99ec6 update nostrlib to fix stuck subscriptions. 2026-03-20 20:56:38 -03:00
Yasuhiro Matsumoto
29db04b7ea fix: add missing target flag to nutzap command 2026-03-20 13:44:14 -03:00
fiatjaf
59fecacbdc ensure we're adding a "K" tag to kind:1111 events. 2026-03-17 20:48:53 -03:00
fiatjaf
faac4d9440 ensure an event is not resigned if it was already signed and wasn't changed. 2026-03-17 19:32:47 -03:00
fiatjaf
f00a4a7d2a fix potential double-yield() usage on reading lines from stdin. 2026-03-17 14:57:42 -03:00
fiatjaf
d823ac55a6 fix nostrlib dependency. 2026-03-17 14:57:11 -03:00
fiatjaf
105ccef7ec gift: fix potential keys ordering and rumor decryption failure check. 2026-03-17 14:56:55 -03:00
fiatjaf
9d4df21836 gift/encrypt: fix wording and yield returns. 2026-03-17 11:49:09 -03:00
fiatjaf
952d638ac3 git: fix --owner flag in "init".
fixes https://github.com/fiatjaf/nak/issues/113
2026-03-16 16:09:00 -03:00
fiatjaf
c1e72e0af1 convert tags to appropriate hex pubkeys, ids etc, even from nip05, but check the tag name first for better (hardcoded) context. 2026-03-13 00:30:46 -03:00
fiatjaf
5f4efdbc69 'group forum' with the ui like 'nak git issue'. 2026-03-12 19:03:20 -03:00
fiatjaf
4967db13a1 update nostrlib dependency. 2026-03-12 16:50:19 -03:00
fiatjaf
da0b753371 group: edit-metadata as PUT, not PATCH. 2026-03-12 16:33:50 -03:00
fiatjaf
bef67d35d2 groups: replace no-text with supported_kinds support. 2026-03-12 16:20:23 -03:00
fiatjaf
a8fb2e4189 git: allow closing a patch as applied. 2026-03-12 16:19:58 -03:00
fiatjaf
7aea4cf9a1 git: ensure some operations are only completed if you're a maintainer. 2026-03-08 21:24:28 -03:00
fiatjaf
d472efe707 git: issue and patch closing. 2026-03-08 21:06:34 -03:00
fiatjaf
17341b3af6 git: issue and patch listing unified in base command. 2026-03-08 21:02:52 -03:00
fiatjaf
ff5a7b4ba7 git: issue replying in the middle of a thread. 2026-03-08 18:29:53 -03:00
mattn
7596e317b8 Merge pull request #109 from fiatjaf/fix/group-forum-tag-filter
fix: remove incorrect '#' prefix from TagMap key in group forum filter
2026-03-08 11:24:24 +09:00
mattn
da7fc3fac3 Merge pull request #108 from mattn/fix/bunker-format-verbs
fix: use %s instead of %w in log/color.RedString format strings
2026-03-08 11:22:48 +09:00
mattn
0e8475b388 Merge pull request #107 from mattn/fix/wallet-typo
fix typo
2026-03-08 11:19:04 +09:00
Yasuhiro Matsumoto
5532c884bc fix: use %s instead of %w in log/color.RedString format strings
%w is only valid in fmt.Errorf. When used in fmt.Sprintf (via
color.RedString) or log, it produces garbled output like
"%!w(MISSING)" and the actual error is not displayed.
2026-03-08 11:15:14 +09:00
Yasuhiro Matsumoto
05857ab190 fix: typo in wallet error message ("walle" -> "wallet") 2026-03-08 11:14:04 +09:00
Yasuhiro Matsumoto
bf22a63404 fix: remove incorrect '#' prefix from TagMap key in group forum filter
The forum subcommand used "#h" as the TagMap key, but TagMap keys
should not include the '#' prefix. All other TagMap usages in this
file correctly use just the tag letter (e.g. "d", "h").
2026-03-08 11:13:42 +09:00
fiatjaf
6bd2d1cdfc git: issues and patches improved enormously. 2026-03-07 23:10:00 -03:00
fiatjaf
9c8d59c5c5 group: talk prints URL to meet.livekit.io 2026-03-07 23:10:00 -03:00
fiatjaf
5957c08d15 git: issues and patches. 2026-03-06 18:40:54 -03:00
fiatjaf
f59c8a670d group: editing groups with livekit metadata and getting jwt from the server (can be used with meet.livekit.io for now). 2026-03-05 22:49:23 -03:00
fiatjaf
61a3b89d08 req: handle values from closeds better.
(it may be closed when all is ok, so we shouldn't rely on yielding an actual value)
2026-03-04 16:16:46 -03:00
fiatjaf
d61fdc4cb4 git: fix first publishing of a new repository announcement. 2026-03-04 16:01:22 -03:00
fiatjaf
0735ded0fc update nostrlib to hopefully fix issues with zombie connections. 2026-03-04 00:10:20 -03:00
fiatjaf
5233a77510 nak profile 2026-02-23 20:56:21 -03:00
Alex Gleason
fd34cc7c5e git clone: support NIP-05 names in nostr:// URIs
Resolve NIP-05 identifiers (e.g. chad@chadwick.site) in the owner
position of nostr:// URIs, so 'nak git clone nostr://user@domain/relay/id'
works without manually resolving to an npub first.
2026-02-20 23:11:36 -03:00
JannikStreek
2d151c2ac8 Fix: docker build 2026-02-19 10:58:54 -03:00
fiatjaf
037e8efcc6 count: support reading filters from stdin like req. 2026-02-12 14:34:24 -03:00
fiatjaf
1b380dea9a count: support the same flags as req.
fixes https://github.com/fiatjaf/nak/issues/103
2026-02-12 14:25:57 -03:00
fiatjaf
f126b3f7ee mcp: searching for pubkeys and nip05s returns them directly. 2026-02-06 13:52:15 -03:00
fiatjaf
dc5ffe5129 bunker: fix authorizing pubkeys from --authorized-secrets.
closes https://github.com/fiatjaf/nak/issues/102
2026-02-04 23:26:26 -03:00
Yasuhiro Matsumoto
7637b5018f avoid panic when mint URL has no '://' 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
d5ab34bb2f remove redundant if err != nil in main 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
49345333c4 check dateparser error before using date.Time 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumoto
b5de7b78bc prevent panic on AUTH challenge tag (nil or len<2) 2026-02-01 14:24:51 -03:00
fiatjaf
ba9a5badc6 uselessly change some words. 2026-01-27 09:25:34 -03:00
fiatjaf
c0bbf73961 update nostrlib to fix decoding of "note1".
fixes https://github.com/fiatjaf/nak/issues/99
2026-01-26 23:45:12 -03:00
fiatjaf
5320feee4f more installation formulas. 2026-01-26 22:46:36 -03:00
fiatjaf
58c1fab0f0 mcp: search method. 2026-01-26 22:34:33 -03:00
fiatjaf
5f30009e72 decode: print error when failed. 2026-01-26 22:34:33 -03:00
fiatjaf
548918578b add new capabilities to README. 2026-01-26 22:34:33 -03:00
Alex Gleason
7757400ab3 Add easy install script 2026-01-26 22:27:28 -03:00
fiatjaf
3ee6320312 bunker: ignore duplicates caused by switch_relays. 2026-01-21 23:17:00 -03:00
fiatjaf
91474d65eb bunker: set default relays so switch_relays works. 2026-01-21 22:19:00 -03:00
fiatjaf
7d782737c4 git status: fix commit printing. 2026-01-21 14:36:25 -03:00
fiatjaf
9160c68cb5 bunker: using unix sockets. 2026-01-21 14:31:12 -03:00
fiatjaf
bf19f38996 nak bunker connect 'nostrconnect://...' working. 2026-01-21 12:44:40 -03:00
fiatjaf
4e2c136e45 nostrconnect:// beginnings. 2026-01-20 17:19:30 -03:00
fiatjaf
8cef1ed0ea group: publishing moderation actions. 2026-01-20 12:52:00 -03:00
fiatjaf
e05b455a05 group: publishing chat messages. 2026-01-18 23:38:03 -03:00
fiatjaf
9190c9d988 nip29/group command with read-only functionality for now. 2026-01-18 23:18:16 -03:00
fiatjaf
e64ad8f078 git: better printing of server statuses. 2026-01-18 21:44:06 -03:00
fiatjaf
b36718caaa git: status.
and a fix for repository announcements getting updated every time due to time shifts.
2026-01-18 21:32:01 -03:00
fiatjaf
5c658c38f1 bring back old github actions builder. 2026-01-18 14:55:03 -03:00
fiatjaf
2a5ce3b249 blossom mirror to only take a URL and do its thing, not try to list blobs. 2026-01-18 14:47:28 -03:00
fiatjaf
c0b85af734 make cgofuse the default for "fs" only on windows. on linux and mac it needs a "cgofuse" build tag. 2026-01-18 10:54:34 -03:00
Yasuhiro Matsumoto
cb2247c9da implement blossom mirror 2026-01-17 11:07:47 -03:00
mattn
686d960f62 Merge pull request #96 from mattn/fix-release
fix release build
2026-01-17 21:14:28 +09:00
Yasuhiro Matsumoto
af04838153 fix release build 2026-01-17 20:51:01 +09:00
fiatjaf
c6da13649d hopefully eliminate the weird case of cron and githubactions calling nak with an empty stdin and causing it to do nothing.
closes https://github.com/fiatjaf/nak/issues/90
2026-01-16 16:11:07 -03:00
Yasuhiro Matsumoto
acd6227dd0 fix darwin build 2026-01-16 15:17:29 -03:00
mattn
00fbda9af7 use native runner and install macfuse 2026-01-16 13:43:19 -03:00
fiatjaf
e838de9b72 fs: move everything to the top-level directory. 2026-01-16 12:34:09 -03:00
fiatjaf
6dfbed4413 fs: just some renames. 2026-01-16 12:18:32 -03:00
fiatjaf
0e283368ed bunker: authorize preexisting keys first. 2026-01-16 12:15:07 -03:00
mattn
38775e0d93 Use cgofuse (#92) 2026-01-14 22:41:14 -03:00
fiatjaf
fabcad3f61 key: fix stupid error when passing nsec1 code to nak key public. 2026-01-10 09:56:39 -03:00
fiatjaf
69e4895e48 --outbox flag for encode. 2026-01-08 22:15:54 -03:00
fiatjaf
81524de04f gift: unwrap tries both decoupled and identity keys, wrap defaults to decoupled but accepts flags to change that. 2025-12-30 15:28:06 -03:00
fiatjaf
8334474f96 git: add viewsource.win to list of possible web views. 2025-12-29 19:53:07 -03:00
fiatjaf
87f27e214e use dekey by default on gift wrap and unwrap. 2025-12-28 12:49:53 -03:00
fiatjaf
32999917b4 actually run the smoke test. 2025-12-27 14:48:17 -03:00
fiatjaf
a19a179548 dekey: don't publish device announcements when not necessary, delete them when they become unnecessary. 2025-12-24 11:46:43 -03:00
fiatjaf
9b684f2c65 dekey: make it better and fix things, --rotate and other flags, prompts by default etc. 2025-12-24 00:09:50 -03:00
fiatjaf
6d87887855 spell: execute from history using the name, not only the id. 2025-12-23 21:32:54 -03:00
fiatjaf
e9c4deaf6d nak req --spell for creating spells. 2025-12-23 21:31:54 -03:00
fiatjaf
965a312b46 only build with lmdb on linux. 2025-12-22 13:01:26 -03:00
fiatjaf
2e4079f92c freeze nostrlib version. 2025-12-22 12:25:34 -03:00
fiatjaf
5b64795015 git: fix --tags 2025-12-22 12:24:03 -03:00
fiatjaf
5d4fe434c3 spell: also take a --pub to run spells in the context of other users. 2025-12-22 12:11:27 -03:00
fiatjaf
b95665d986 spell: store spells locally and add stdin spell to history. 2025-12-22 11:59:29 -03:00
fiatjaf
3be80c29df spell: fix listing recent. 2025-12-22 00:22:14 -03:00
fiatjaf
e01cfbde47 print spell details before running. 2025-12-22 00:19:10 -03:00
fiatjaf
e91d4429ec switch the local databases to lmdb so they can be accessed by multiple nak instances at the same time. 2025-12-22 00:18:56 -03:00
fiatjaf
21423b4a21 spell: execute from stdin event. 2025-12-21 21:59:44 -03:00
fiatjaf
1b7f3162b5 automatically create hints, store, and kvstore for system always. 2025-12-21 21:58:22 -03:00
fiatjaf
8f38468103 basic grimoire spell support. 2025-12-21 15:11:08 -03:00
fiatjaf
9bf728d850 git: nip34 state as fake heads instead of fake remotes. 2025-12-21 15:11:00 -03:00
fiatjaf
8396738fe2 git: push --tags support. 2025-12-21 15:11:00 -03:00
fiatjaf
c1d1682d6e dekey: add logs. 2025-12-19 14:50:59 -03:00
fiatjaf
6f00ff4c73 bunker: fix a halting waitgroup issue. 2025-12-16 13:13:12 -03:00
fiatjaf
68bbece3db update keypair pee example on readme. 2025-12-16 13:12:56 -03:00
fiatjaf
a83b23d76b add nak git demo to README. 2025-12-05 22:15:08 -03:00
fiatjaf
a288cc47a4 add example of compilation with -tags debug to README. 2025-12-05 22:09:22 -03:00
fiatjaf
5ee7670ba8 req: fix infinite loop when events channel is exhausted. 2025-12-04 13:21:43 -03:00
fiatjaf
b973b476bc req: print CLOSED messages. 2025-12-04 09:24:36 -03:00
fiatjaf
252612b12f add pee trick. 2025-12-04 08:46:20 -03:00
fiatjaf
4b8b6bb3de dekey: nip4e (untested). 2025-12-03 23:08:59 -03:00
fiatjaf
df491be232 serve: --grasp-path (hidden). 2025-12-02 15:53:18 -03:00
fiatjaf
1dab81f77c add examples to README. 2025-12-01 21:16:01 -03:00
fiatjaf
11228d7082 gift-wrap. 2025-12-01 21:02:20 -03:00
fiatjaf
a422b5f708 sync command for using a negentropy hack to sync two relays with each other.
closes https://github.com/fiatjaf/nak/issues/84
2025-12-01 20:33:18 -03:00
fiatjaf
852fe6bdfb git: more resiliency when updating nip34.json 2025-11-30 22:21:56 -03:00
fiatjaf
210cf66d5f git: fix a bunch of small bugs. 2025-11-30 08:57:27 -03:00
fiatjaf
f9335b0ab4 git: fetch repo from owner+identifier on init, and other things. 2025-11-27 23:59:46 -03:00
fiatjaf
16916d7d95 nip: display markdown directly, default to list. 2025-11-27 12:14:02 -03:00
fiatjaf
3ff4dbe196 force update golang version.
fixes nostr:nevent1qvzqqqqqqypzqwlsccluhy6xxsr6l9a9uhhxf75g85g8a709tprjcn4e42h053vaqydhwumn8ghj7un9d3shjtnhv4ehgetjde38gcewvdhk6tcprfmhxue69uhhq7tjv9kkjepwve5kzar2v9nzucm0d5hsqgzdaekrxfhwrex49f6htd7rvmnfxs40ypga9mx7hvssaz347mxees2gpdzr
2025-11-27 07:06:53 -03:00
reis
2de3ff78ee Add nip command (#83) 2025-11-26 09:02:47 -03:00
fiatjaf
03c1bf832e fix README misformatting. 2025-11-25 22:44:03 -03:00
fiatjaf
8df130a822 git: handle "pull" modes correctly and stop deleting and recreating remotes all the time. 2025-11-25 14:51:24 -03:00
fiatjaf
e04861fcee git: allow gitSync to not fail if the state is broken. 2025-11-25 14:51:24 -03:00
Yasuhiro Matsumoto
73d80203a0 fix error message 2025-11-25 14:35:16 -03:00
fiatjaf
c3cb59a94a git: move things around, allow for nil state as a possible value, fix syncing when repository is not announced yet. 2025-11-24 23:33:07 -03:00
fiatjaf
59edaba5b8 git: much nicer prompts on "init". 2025-11-24 15:46:44 -03:00
fiatjaf
11a690b1c6 git: fix sync publishing wrong repo event and always being mismatched. 2025-11-24 06:37:42 -03:00
fiatjaf
9f8679591e git: remove unused gitAnnounce. 2025-11-23 21:33:32 -03:00
fiatjaf
75c1a88333 git: push needed to update refs from the state after pushing. 2025-11-23 21:33:19 -03:00
fiatjaf
26fc7c338a git: nip34.json into repository object helpers. 2025-11-23 21:32:33 -03:00
fiatjaf
ddc009a391 git: rework it to be more git-native and expose the internals more in a cool way. 2025-11-23 18:01:25 -03:00
fiatjaf
68e49fa6e5 git: fix the local/remote madness finally I think. 2025-11-23 14:50:19 -03:00
fiatjaf
79c1a70683 git: cleanup. 2025-11-21 23:25:23 -03:00
fiatjaf
77afab780b git: fetch and pull (wip). 2025-11-21 20:01:55 -03:00
fiatjaf
a4f53021f0 add examples for newer use cases. 2025-11-21 20:01:55 -03:00
fiatjaf
afa31a58fc serve: --negentropy 2025-11-21 20:01:55 -03:00
fiatjaf
26f9b33d53 git clone 2025-11-20 23:51:45 -03:00
fiatjaf
51876f89c4 git: nicer logs and fix announce to update only and all outdated relays. 2025-11-19 00:29:30 -03:00
fiatjaf
ae3cb7c108 serve: blossom and grasp support. 2025-11-19 00:29:30 -03:00
fiatjaf
bec821d3c0 build with latest nostrlib.
we had to do this git thing just so we could publish nostrlib to grasp servers and make it downloadable as a dependency, now finally.
2025-11-18 11:57:16 -03:00
fiatjaf
5d7240b112 git betterments with remote and branch determination, force-push and fast-forward check. 2025-11-18 08:14:12 -03:00
Lez
bbe1661096 Don't emit hello event if no events were received from stdin
When running `nak req ... relay.one | nak event relay.two`,
if the first req doesn't return any events, the second
nak should not publish a "hello from nostr army knife" note
to the second relay as it is clearly not the intention.

`nak event relay.two` behavior is unchanged, it will publish the hello.
2025-11-18 08:13:47 -03:00
fiatjaf
ea4ad84aa0 "nak git" command with "init", "announce" and "push". 2025-11-17 13:05:47 -03:00
fiatjaf
85a04aa7ce req --only-missing for negentropy downloading. 2025-11-13 16:16:34 -03:00
fiatjaf
e0ca768695 also parse npub/nevent/naddr when used as tag values, turn them into their corresponding hex or address format. 2025-11-11 16:32:14 -03:00
fiatjaf
bef3739a67 accept npub/nprofile/nevent instead of just hex in flags. 2025-11-11 15:58:53 -03:00
fiatjaf
210c0aa282 update nostrlib again, mostly for the blossom client timeout issue. 2025-11-04 09:18:21 -03:00
fiatjaf
2758285d51 update nostrlib. 2025-09-08 11:11:07 -03:00
fiatjaf
ecb7f8f195 event: renew relay connection before publishing if necessary. 2025-09-07 18:56:51 -03:00
fiatjaf
9251702460 query batching on nak req --outbox. 2025-09-06 22:21:11 -03:00
fiatjaf
13452e6916 fix nostrlib dependency. 2025-09-06 07:39:25 -03:00
fiatjaf
cdd64e340f nak req --outbox 2025-09-05 17:12:21 -03:00
fiatjaf
3b4d6046cf nak admin: for nip86 management (the previous command was broken). 2025-09-04 13:04:13 -03:00
fiatjaf
bf1690a041 get rid of badger, replace with bolt, following nostrlib. 2025-09-03 21:37:03 -03:00
fiatjaf
88031c888b wallet --stream 2025-08-29 16:25:41 -03:00
fiatjaf
6f0e777324 wallet tokens drop 2025-08-29 16:25:38 -03:00
fiatjaf
b316646821 new release with updated dependencies. 2025-08-18 21:01:52 -03:00
fiatjaf
d3975679e4 add labels to subscriptions for easier debugging. 2025-08-14 13:28:15 -03:00
fiatjaf
23e27da077 use isatty for detecting stuff for the fancy output (it doesn't work). 2025-08-14 13:28:15 -03:00
fiatjaf
1a221a133c cleanup and fix readme. 2025-08-14 13:28:15 -03:00
George
a698c59b0b fix build on OpenBSD 2025-07-23 16:11:57 -03:00
fiatjaf
87bf5ef446 fix nak blossom list stupid segfault. 2025-07-17 20:00:03 -03:00
fiatjaf
7c58948924 verify: better handling of stdout and verbose logging output.
fixes: https://github.com/fiatjaf/nak/issues/74
2025-07-17 16:14:36 -03:00
fiatjaf
ff02e6890b adapt to nostr lib websocket refactor commit (which includes the filters thing). 2025-07-11 13:02:06 -03:00
fiatjaf
fb377f4775 reword some things. 2025-07-05 11:14:29 -03:00
Anthony Accioly
b1114766e5 docs(readme): update caution note with encryption guidance
- Revise the caution note to include instructions for encrypting private keys using NIP-49.
2025-07-04 14:45:38 -03:00
Anthony Accioly
e0febbf190 docs(readme): remove outdated contributing section
- Delete the outdated contributing section referencing NIP-34.
2025-07-04 14:45:38 -03:00
Anthony Accioly
2d2e657778 docs(readme): caution note on plaintext credential storage
- Add a warning about credentials being stored in plain text when using
`--persist`.
2025-07-04 14:45:38 -03:00
Anthony Accioly
d32654447a feat(bunker): add QR code generation for bunker URI
- Add `--qrcode` flag to display a QR code for the bunker URI.
- Update `README.md` with usage instructions for the new flag.
- Include `qrterminal` dependency for QR code generation.
2025-07-04 14:45:38 -03:00
mplorentz
fea23aecc3 Add Dockerfile
I added this so that I could run a nak bunker on my server alongside my other containers. Thought it might be useful for others.
2025-07-02 23:28:40 -03:00
fiatjaf
cc526acb10 bunker: fix overwriting all keys always with default. 2025-07-01 15:52:44 -03:00
fiatjaf
fd19855543 remove a dangling print statement. 2025-07-01 12:43:04 -03:00
fiatjaf
ecfe3a298e add persisted bunker and filter examples to readme. 2025-07-01 12:42:57 -03:00
fiatjaf
9c5f68a955 bunker: fix handling of provided and stored secret keys. 2025-07-01 12:36:54 -03:00
fiatjaf
0aef173e8b nak bunker --persist/--profile 2025-07-01 11:40:34 -03:00
fiatjaf
6e4a546212 release with fixes. 2025-06-27 16:34:59 -03:00
fiatjaf
55c9d4ee45 remove the bunker context timeout because it causes the entire bunker to disconnect. 2025-06-27 16:28:21 -03:00
fiatjaf
550c89d8d7 slightly improve some error messages. 2025-06-27 13:50:28 -03:00
fiatjaf
1e9be3ed84 nak filter 2025-06-27 13:49:40 -03:00
fiatjaf
79cbc57dde fix main command error handler printing wrongly formatted stuff. 2025-06-27 13:48:07 -03:00
fiatjaf
1e237b4c42 do not fill .Content when "content" is received empty from stdin.
fixes https://github.com/fiatjaf/nak/issues/71
2025-06-23 17:57:45 -03:00
fiatjaf
89ec8b9822 simplify README about $NOSTR_CLIENT_KEY. 2025-06-20 21:06:42 -03:00
Anthony Accioly
fba83ea39e docs(readme): clarify NIP-46 signing with remote bunker
- Add example linking to Amber for NIP-46 bunker usage.
- Include note on setting `NOSTR_CLIENT_KEY`
2025-06-20 21:02:57 -03:00
Anthony Accioly
bd5569955c fix(helpers): add timeout and verbose logging for bunker connection
- Add a 10-second timeout to the bunker connection process using context
- Include detailed verbose logging for debugging.
2025-06-20 21:02:57 -03:00
Rui Chen
35ea2582d8 fix: update go.sum to fix build
Signed-off-by: Rui Chen <rui@chenrui.dev>
2025-06-20 16:24:15 -03:00
fiatjaf
fa63dbfea3 release v0.14.3 2025-06-20 11:06:09 -03:00
fiatjaf
a6509909d0 nostrfs: update pointer thing from nostrlib. 2025-06-08 10:50:08 -03:00
fiatjaf
239dd2d42a add example of recording and publishing a voice note. 2025-05-25 23:34:05 -03:00
fiatjaf
0073c9bdf1 compile tests again. 2025-05-23 07:52:19 -03:00
Chris McCormick
b5bd2aecf6 Run smoke test on release workflow success. 2025-05-23 07:49:54 -03:00
Chris McCormick
f27ac6c0e3 Basic smoke tests. 2025-05-23 07:49:54 -03:00
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
fiatjaf
a4d9ceecfa do it again because blergh. 2024-08-20 17:13:01 -03:00
fiatjaf
56657d8aa9 update go-nostr. 2024-08-20 15:10:18 -03:00
fiatjaf
ea7b88cfd7 fix fetch with nip05 filter and make req filter options generalize to fetch.
related: https://github.com/fiatjaf/nak/issues/19
2024-08-20 10:59:38 -03:00
fiatjaf
2042b14578 nak fetch: support nip05 codes.
addresses https://github.com/fiatjaf/nak/issues/19
2024-08-20 10:48:09 -03:00
fiatjaf
9d43e66fac nak event --pow
closes https://github.com/fiatjaf/nak/issues/29
2024-08-20 10:34:47 -03:00
fiatjaf
85e9610265 test natural timestamps. 2024-08-20 10:29:00 -03:00
fiatjaf
2edfa5cbea nak serve 2024-08-19 12:49:52 -03:00
fiatjaf
9690dc70cb nak req --paginate 2024-08-18 23:38:03 -03:00
fiatjaf
c90e61dbec set .DisableSliceFlagSeparator to true.
fixes nostr:nevent1qqs9qwgwnr2rzguzrgt99hhhyv8e84mcdr4mnk86uvm6ndjvzl4rjxqpzpmhxue69uhkztnwdaejumr0dshsz9mhwden5te0vf5hgcm0d9hx2u3wwdhkx6tpdshszxnhwden5te0vfhhxarj9ekx2cm5w4exjene9ehx2ap0j8u0fj
2024-08-07 11:46:08 -03:00
fiatjaf
d226cd6ce4 fix password input lowercasing characters.
fixes https://github.com/fiatjaf/nak/issues/28
2024-08-06 11:05:08 -03:00
fiatjaf
3d78e91f62 bunker: deny getPublicKey() even though it's harmless.
fixes https://github.com/fiatjaf/nak/issues/27
2024-08-06 10:56:08 -03:00
fiatjaf
84965f2253 don't set limit to zero on --stream 2024-08-03 10:52:46 -03:00
fiatjaf
928c73513c just move imports around. 2024-07-30 11:43:14 -03:00
fiatjaf
a36142604d compile to riscv64. 2024-07-27 10:32:32 -03:00
fiatjaf
220fe84f1b hardcode our fork of urfave/cli because go is stupid.
fixes https://github.com/fiatjaf/nak/issues/26
2024-07-23 15:23:13 -03:00
fiatjaf
48c0e342e3 only look for private key in environment variable if --sec is not given. 2024-07-23 15:21:14 -03:00
jeremyd
ec2e214c02 Allow setting private key via ENV variable 2024-07-23 15:18:09 -03:00
fiatjaf
9f62d4679f increase stdin line limit. 2024-07-17 13:16:40 -03:00
fiatjaf
809865ca0c relay management: adhere to NIP-98 stupidity. 2024-07-16 13:26:54 -03:00
fiatjaf
813ab3b6ac test flags after args. 2024-07-14 20:34:44 -03:00
fiatjaf
09ed2a040a make tests work again. 2024-07-14 16:18:34 -03:00
fiatjaf
7846960c4e use latest fixed version of urfave/cli fork with reorder flags fixed. 2024-07-14 12:46:13 -03:00
fiatjaf
ce6bb0aa22 natural time examples. 2024-07-13 13:17:20 -03:00
fiatjaf
49ce12ffc2 use natural date parser thing for req "since", "until" and event "ts". 2024-07-13 13:07:49 -03:00
fiatjaf
a5013c513d disallow negative kinds and limits. 2024-07-13 13:07:49 -03:00
fiatjaf
8f51fe757b remove nson.
it's not being used by anyone and didn't gain enough traction, and also
now I think I have a more efficient way of encoding this, so using that
new scheme in the future will be better than this.
2024-07-13 13:06:51 -03:00
fiatjaf
30ca5776c5 global -q is persistent. 2024-07-13 09:42:43 -03:00
fiatjaf
e18e8c00e7 add 9 new examples to readme. 2024-07-12 19:17:22 -03:00
fiatjaf
bca4362ca5 fix inconsistency in nak key decrypt output: print hex always. 2024-07-12 19:15:13 -03:00
fiatjaf
54c4be10bd fix and improve flag reordering for subcommands. 2024-07-12 18:51:07 -03:00
fiatjaf
27f925c05e left pad keys on nak key too so nak key public 02 works, for example. 2024-07-12 14:04:14 -03:00
fiatjaf
79cb63a1b4 add all other relay management rpc methods. 2024-07-12 13:57:58 -03:00
fiatjaf
5ee0036128 implement nip86 client: making management RPC calls to relays. 2024-07-11 15:34:15 -03:00
fiatjaf
316d94166e fix lineProcessingError() -- it wasn't returning a ctx so it was a noop. 2024-07-11 15:33:44 -03:00
fiatjaf
2ca6bb0940 update tests so they can run again (but they're not working). 2024-07-10 14:48:18 -03:00
fiatjaf
ac00c5065f nak req: --force-pre-auth flag. 2024-07-10 14:48:02 -03:00
fiatjaf
441ee9a5ed update go-nostr so just "localhost[:port]" works as a relay url. 2024-07-05 00:11:59 -03:00
fiatjaf
9a41450209 use modified cli library that accepts flags after arguments. 2024-06-25 23:23:51 -03:00
fiatjaf
dba2ed0b5f update to cli v3. 2024-06-25 22:18:26 -03:00
fiatjaf
2079ddf818 support prompting for a password on nak decrypt. 2024-06-25 13:46:15 -03:00
fiatjaf
2135b68106 more aliases in nak encode flags. 2024-06-13 12:05:27 -03:00
fiatjaf
9f98a0aea3 fix nak key encrypt reading from stdin. 2024-06-12 08:54:00 -03:00
fiatjaf
1ba39ca7d7 print bunker restart command without schemes in relay urls when possible. 2024-06-07 06:30:58 -03:00
fiatjaf
262c0c892a fix simple test event ordering. 2024-06-06 15:43:42 -03:00
fiatjaf
363bd66a8a accept relay URLs without scheme everywhere. 2024-06-06 15:38:40 -03:00
fiatjaf
eccce6dc4a nak key combine now returns all possible combinations. 2024-05-16 19:50:52 -03:00
fiatjaf
31f007ffc2 fix: musig2 event to cli args was generating multivalue tags wrongly. 2024-05-15 18:49:22 -03:00
fiatjaf
bb45059218 refactor bunker to work better. remove prompts, use lists of keys and secrets and a new random key. 2024-05-15 17:31:01 -03:00
fiatjaf
71dfe583ed rename flags from --musig2-... to --musig-..., add id to event and other small tweaks. 2024-05-14 23:52:56 -03:00
fiatjaf
84bde7dacd musig2 works now. 2024-05-14 23:41:12 -03:00
fiatjaf
81968f6c0c nak key combine and nak event --musig2 2024-05-14 15:23:08 -03:00
fiatjaf
f198a46c19 remove wss:// from relay urls in readme. 2024-04-29 08:36:59 -03:00
fiatjaf
c3ea9c15f6 LimitZero when -l 0 and when --stream 2024-03-29 08:16:28 -03:00
Yasuhiro Matsumoto
8ddb9ce021 fix color output on Windows 2024-03-28 15:52:30 -03:00
fiatjaf
569d38a137 accept multiple arguments in many commands, add a lot of more tests. 2024-03-19 11:34:59 -03:00
fiatjaf
34c189af28 bunker improvements. 2024-03-02 08:18:40 -03:00
fiatjaf
ffe2db7f96 event: accept tags with a single item. 2024-02-21 09:47:34 -03:00
67 changed files with 19820 additions and 1262 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

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

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

497
README.md

File diff suppressed because one or more lines are too long

208
admin.go Normal file
View File

@@ -0,0 +1,208 @@
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,
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},
{"listallowedevents", nil},
{"listdisallowedkinds", nil},
{"supportedmethods", nil},
{"stats", nil},
{"grantadmin", []string{"pubkey", "methods"}},
{"revokeadmin", []string{"pubkey", "methods"}},
{"createrole", []string{"role_id", "label", "description", "color", "order"}},
{"editrole", []string{"role_id", "label", "description", "color", "order"}},
{"deleterole", []string{"role_id"}},
{"assignrole", []string{"pubkey", "role_id"}},
{"unassignrole", []string{"pubkey", "role_id"}},
}
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)
resp.Body.Close()
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("%s", 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("%s", bodyPrintable)
continue
}
// 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 "pubkey":
return &PubKeyFlag{Name: argName, Usage: usage}
case "id":
return &IDFlag{Name: argName, Usage: usage}
case "kind", "order", "color":
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
case "reason":
return &cli.StringFlag{Name: argName, Usage: usage}
case "methods":
return &cli.StringSliceFlag{Name: argName, Required: true, Usage: usage}
default:
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
}
}
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "pubkey":
return getPubKey(c, argName)
case "id":
return getID(c, argName)
case "kind", "order", "color":
return c.Int(argName)
case "methods":
return c.StringSlice(argName)
default:
return c.String(argName)
}
}

328
blossom.go Normal file
View File

@@ -0,0 +1,328 @@
package main
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"io"
"os"
"unsafe"
"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: combineFlags([][]cli.Flag{},
&cli.StringSliceFlag{
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)
}
servers := c.StringSlice("server")
if err != nil {
return err
}
client = blossom.NewClient(servers[0], keyer.NewReadOnlySigner(pk))
} else {
var err error
client, err = getBlossomClient(ctx, c)
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 {
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
servers := c.StringSlice("server")
if len(servers) == 0 {
return fmt.Errorf("no server specified")
}
if isPiped() {
// get file from stdin
if c.Args().Len() > 0 {
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)
}
for _, server := range servers {
client := blossom.NewClient(server, keyer)
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
if err != nil {
ctx = lineProcessingError(ctx, "failed to upload to '%s': %s", server, err)
continue
}
j, _ := json.Marshal(bd)
stdout(string(j))
}
} else {
// get filenames from arguments
for _, fpath := range c.Args().Slice() {
for _, server := range servers {
client := blossom.NewClient(server, keyer)
bd, err := client.UploadFilePath(ctx, fpath)
if err != nil {
ctx = lineProcessingError(ctx, "failed to upload '%s' to '%s': %s", fpath, server, err)
continue
}
j, _ := json.Marshal(bd)
stdout(string(j))
}
}
}
exitIfLineProcessingError(ctx)
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
var hash [32]byte
for i, hhash := range c.Args().Slice() {
if len(hhash) != 64 {
log("invalid blob hash '%s'\n", hhash)
hasError = true
continue
}
if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(hhash), 64)); err != nil {
log("invalid blob hash '%s': %s\n", hhash, err)
hasError = true
continue
}
if len(outputs)-1 >= i && outputs[i] != "--" {
// save to this file
err := client.DownloadToFile(ctx, hash, outputs[i])
if err != nil {
log("download failed for '%s': %s\n", hhash, 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
}
os.Stdout.Write(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() {
// a sha256 is 64 hex characters, anything after that is an extension
if len(hash) > 64 {
hash = hash[0:64]
}
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: "mirrors a from a server to another",
Description: `examples:
mirroring a single blob:
nak blossom mirror https://nostr.download/5672be22e6da91c12b929a0f46b9e74de8b5366b9b19a645ff949c24052f9ad4 -s blossom.band
mirroring all blobs from a certain pubkey from one server to the other:
nak blossom list 78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d -s nostr.download | nak blossom mirror -s blossom.band`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
var bd blossom.BlobDescriptor
if input := c.Args().First(); input != "" {
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
return err
}
out, _ := json.Marshal(bd)
stdout(string(out))
return nil
}
for input := range getJsonsOrBlank() {
if input == "{}" {
continue
}
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
ctx = lineProcessingError(ctx, "failed to mirror '%s': %s", blobURL, err)
continue
}
out, _ := json.Marshal(bd)
stdout(string(out))
}
exitIfLineProcessingError(ctx)
return nil
},
},
},
}
func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, error) {
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return nil, err
}
servers := c.StringSlice("server")
if err != nil {
return nil, err
}
return blossom.NewClient(servers[0], keyer), nil
}

774
bunker.go
View File

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

245
cli_test.go Normal file
View File

@@ -0,0 +1,245 @@
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)
}
func TestEventJQ(t *testing.T) {
// default: string results come out JSON-quoted, like plain jq
quoted := call(t, "nak event --ts 1699485669 -c hello --jq .content")
require.Equal(t, `"hello"`, quoted)
// --jq-raw: string results come out unquoted, like `jq -r`
raw := call(t, "nak event --ts 1699485669 -c hello --jq .content --jq-raw")
require.Equal(t, "hello", raw)
// --jq-raw on a non-string result is still JSON-encoded, like `jq -r`
num := call(t, "nak event --ts 1699485669 -k 7 --jq .kind --jq-raw")
require.Equal(t, "7", num)
}

228
count.go
View File

@@ -1,147 +1,117 @@
package main
import (
"encoding/json"
"errors"
"context"
"fmt"
"os"
"strings"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v2"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
var count = &cli.Command{
Name: "count",
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `outputs a NIP-45 request (the flags are mostly the same as 'nak req').`,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
},
ArgsUsage: "[relay...]",
Action: func(c *cli.Context) error {
filter := nostr.Filter{}
if authors := c.StringSlice("author"); len(authors) > 0 {
filter.Authors = authors
}
if ids := c.StringSlice("id"); len(ids) > 0 {
filter.IDs = ids
}
if kinds := c.IntSlice("kind"); len(kinds) > 0 {
filter.Kinds = kinds
Name: "count",
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{reqFilterFlags}),
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,
)
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)
}
}
}
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)
// go line by line from stdin or run once with input from flags
for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{}
if stdinFilter != "" {
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
continue
}
}
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
nm := nostr.NormalizeURL(relayUrl)
relay, ok := sys.Pool.Relays.Load(nm)
if !ok || relay == nil || !relay.IsConnected() {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
var err error
relay, err = nostr.RelayConnect(ct, relayUrl, sys.Pool.RelayOptions)
cancel()
if err != nil {
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
fmt.Fprintf(os.Stderr, "error: %s\n", err)
continue
}
sys.Pool.Relays.Store(nm, relay)
}
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
continue
}
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " (hll)"
}
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
}
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "HyperLogLog sum: %d\n", hll.Count())
}
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
}
if since := c.Int("since"); since != 0 {
ts := nostr.Timestamp(since)
filter.Since = &ts
}
if until := c.Int("until"); until != 0 {
ts := nostr.Timestamp(until)
filter.Until = &ts
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = 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(c.Context, relayUrl)
if err != nil {
failures = append(failures, err)
continue
}
count, err := relay.Count(c.Context, nostr.Filters{filter})
if err != nil {
failures = append(failures, err)
continue
}
fmt.Printf("%s: %d\n", relay.URL, count)
successes++
}
if successes == 0 {
return errors.Join(failures...)
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
exitIfLineProcessingError(ctx)
return nil
},
}

137
curl.go Normal file
View File

@@ -0,0 +1,137 @@
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.",
DisableSliceFlagSeparator: true,
// this command is always run standalone (see realCurl()), never under the root
// command, so it needs its own copy of the key flags
Flags: defaultKeyFlags,
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++
if i+2 >= len(os.Args) {
break
}
keyFlags = append(keyFlags, os.Args[i+2])
}
} else {
curlFlags = append(curlFlags, arg)
}
}
// Run treats the first element as the program name, so prepend one
return curl.Run(context.Background(), append([]string{"nak curl"}, keyFlags...))
}

119
decode.go
View File

@@ -1,14 +1,15 @@
package main
import (
"context"
"encoding/hex"
"encoding/json"
stdjson "encoding/json"
"strings"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
sdk "github.com/nbd-wtf/nostr-sdk"
"github.com/urfave/cli/v2"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"github.com/urfave/cli/v3"
)
var decode = &cli.Command{
@@ -19,6 +20,7 @@ var decode = &cli.Command{
nak decode nevent1qqs29yet5tp0qq5xu5qgkeehkzqh5qu46739axzezcxpj4tjlkx9j7gpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5sh59ud
nak decode nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpz4mhxue69uhk2er9dchxummnw3ezumrpdejqz8thwden5te0dehhxarj94c82c3wwajkcmr0wfjx2u3wdejhgqgcwaehxw309aex2mrp0yhxummnw3exzarf9e3k7mgnp0sh5
nak decode nsec1jrmyhtjhgd9yqalps8hf9mayvd58852gtz66m7tqpacjedkp6kxq4dyxsr`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "id",
@@ -32,79 +34,62 @@ var decode = &cli.Command{
},
},
ArgsUsage: "<npub | nprofile | nip05 | nevent | naddr | nsec>",
Action: func(c *cli.Context) error {
for input := range getStdinLinesOrFirstArgument(c.Args().First()) {
Action: func(ctx context.Context, c *cli.Command) error {
for input := range getStdinLinesOrArguments(c.Args()) {
if strings.HasPrefix(input, "nostr:") {
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 {
lineProcessingError(c, "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(c.Context, 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 {
lineProcessingError(c, "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': %s", input, err)
}
exitIfLineProcessingError(c)
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)
}

437
dekey.go Normal file
View File

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

409
encode.go
View File

@@ -1,13 +1,68 @@
package main
import (
"context"
stdjson "encoding/json"
"fmt"
"iter"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/urfave/cli/v2"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
func parseProfilePointerInput(target string) (nostr.ProfilePointer, bool) {
var profilePtr nostr.ProfilePointer
if err := stdjson.Unmarshal([]byte(target), &profilePtr); err != nil || profilePtr.PublicKey == nostr.ZeroPK {
return nostr.ProfilePointer{}, false
}
return profilePtr, true
}
func parseEventPointerInput(target string) (nostr.EventPointer, bool) {
var eventPtr nostr.EventPointer
if err := stdjson.Unmarshal([]byte(target), &eventPtr); err != nil || eventPtr.ID == nostr.ZeroID {
return nostr.EventPointer{}, false
}
return eventPtr, true
}
func parseEntityPointerInput(target string) (nostr.EntityPointer, bool) {
var entityPtr nostr.EntityPointer
if err := stdjson.Unmarshal([]byte(target), &entityPtr); err != nil || entityPtr.PublicKey == nostr.ZeroPK || entityPtr.Kind == 0 {
return nostr.EntityPointer{}, false
}
return entityPtr, true
}
func getEncodeSubcommandInput(args cli.Args, allowBlank bool) iter.Seq[string] {
if args.Len() > 0 {
return func(yield func(string) bool) {
for _, arg := range args.Slice() {
if !yield(arg) {
return
}
}
}
}
return func(yield func(string) bool) {
for jsonStr := range getJsonsOrBlank() {
if jsonStr == "{}" {
if allowBlank {
yield("")
}
return
}
if !yield(jsonStr) {
return
}
}
}
}
var encode = &cli.Command{
Name: "encode",
Usage: "encodes notes and other stuff to nip19 entities",
@@ -17,53 +72,99 @@ 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(c *cli.Context) error {
if c.Args().Len() < 1 {
return fmt.Errorf("expected more than 1 argument.")
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`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 0 {
switch c.Args().First() {
case "naddr", "nevent", "npub", "nprofile", "nsec":
return nil
}
return fmt.Errorf("unknown encode target '%s'", c.Args().First())
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
hasStdin := false
for jsonStr := range getJsonsOrBlank() {
if jsonStr == "{}" {
hasStdin = false
continue
} else {
hasStdin = true
}
if eventPtr, ok := parseEventPointerInput(jsonStr); ok {
stdout(nip19.EncodeNevent(eventPtr.ID, nostr.AppendUnique(relays, eventPtr.Relays...), eventPtr.Author))
continue
}
if entityPtr, ok := parseEntityPointerInput(jsonStr); ok {
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, nostr.AppendUnique(relays, entityPtr.Relays...)))
continue
}
if profilePtr, ok := parseProfilePointerInput(jsonStr); ok {
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, nostr.AppendUnique(relays, profilePtr.Relays...)))
continue
}
ctx = lineProcessingError(ctx, "couldn't decode JSON '%s'", jsonStr)
}
if !hasStdin {
return nil
}
exitIfLineProcessingError(ctx)
return nil
},
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "npub",
Usage: "encode a hex public key into bech32 'npub' format",
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c.Args().First()) {
if ok := nostr.IsValidPublicKey(target); !ok {
lineProcessingError(c, "invalid public key: %s", target)
Name: "npub",
Usage: "encode a hex public key into bech32 'npub' format",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
pk, err := nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
continue
}
if npub, err := nip19.EncodePublicKey(target); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNpub(pk))
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
{
Name: "nsec",
Usage: "encode a hex private key into bech32 'nsec' format",
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c.Args().First()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
lineProcessingError(c, "invalid private key: %s", target)
Name: "nsec",
Usage: "encode a hex private key into bech32 'nsec' format",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
sk, err := nostr.SecretKeyFromHex(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid private key '%s': %s", target, err)
continue
}
if npub, err := nip19.EncodePrivateKey(target); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNsec(sk))
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -74,29 +175,46 @@ var encode = &cli.Command{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to nprofile code",
Usage: "attach relay hints to the code",
},
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
},
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c.Args().First()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
lineProcessingError(c, "invalid public key: %s", target)
continue
}
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getEncodeSubcommandInput(c.Args(), false) {
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
pk := nostr.ZeroPK
if profilePtr, ok := parseProfilePointerInput(target); ok {
pk = profilePtr.PublicKey
relays = nostr.AppendUnique(relays, profilePtr.Relays...)
} else {
var err error
pk, err = nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
continue
}
}
if getBoolInt(c, "outbox") > 0 {
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
relays = nostr.AppendUnique(relays, r)
}
}
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
if npub, err := nip19.EncodeProfile(target, relays); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNprofile(pk, relays))
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -104,128 +222,163 @@ var encode = &cli.Command{
Name: "nevent",
Usage: "generate event codes with optionally attached relay information",
Flags: []cli.Flag{
&PubKeyFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "attach an author pubkey as a hint to the nevent code",
},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to nevent code",
Usage: "attach relay hints to the code",
},
&cli.StringFlag{
Name: "author",
Usage: "attach an author pubkey as a hint to the nevent code",
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
},
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c.Args().First()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
lineProcessingError(c, "invalid event id: %s", target)
continue
}
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getEncodeSubcommandInput(c.Args(), false) {
id := nostr.ZeroID
author := getPubKey(c, "author")
relays := c.StringSlice("relay")
author := c.String("author")
if author != "" {
if ok := nostr.IsValidPublicKey(author); !ok {
return fmt.Errorf("invalid 'author' public key")
if eventPtr, ok := parseEventPointerInput(target); ok {
id = eventPtr.ID
relays = nostr.AppendUnique(relays, eventPtr.Relays...)
if author == nostr.ZeroPK {
author = eventPtr.Author
}
} else {
var err error
id, err = parseEventID(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue
}
}
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
relays = nostr.AppendUnique(relays, r)
}
}
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
if npub, err := nip19.EncodeEvent(target, relays, author); err == nil {
stdout(npub)
} else {
return err
}
stdout(nip19.EncodeNevent(id, relays, author))
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
{
Name: "naddr",
Usage: "generate codes for NIP-33 parameterized replaceable events",
Usage: "generate codes for addressable events",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "identifier",
Aliases: []string{"d"},
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
Required: true,
Name: "identifier",
Aliases: []string{"d"},
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
},
&cli.StringFlag{
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"p"},
Required: true,
&PubKeyFlag{
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
},
&cli.Int64Flag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
Required: true,
&KindFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "attach relay hints to naddr code",
Usage: "attach relay hints to the code",
},
&BoolIntFlag{
Name: "outbox",
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
},
Action: func(c *cli.Context) error {
for d := range getStdinLinesOrBlank() {
pubkey := c.String("pubkey")
if ok := nostr.IsValidPublicKey(pubkey); !ok {
return fmt.Errorf("invalid '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)
}
if d == "" {
d = c.String("identifier")
if d == "" {
lineProcessingError(c, "\"d\" tag identifier can't be empty")
continue
}
}
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getEncodeSubcommandInput(c.Args(), true) {
pubkey := getPubKey(c, "pubkey")
kind := getKind(c, "kind")
d := c.String("identifier")
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
return err
if entityPtr, ok := parseEntityPointerInput(target); ok {
relays = nostr.AppendUnique(relays, entityPtr.Relays...)
if pubkey == nostr.ZeroPK {
pubkey = entityPtr.PublicKey
}
if kind == 0 {
kind = entityPtr.Kind
}
if !c.IsSet("identifier") {
d = entityPtr.Identifier
}
} else if target != "" {
d = target
}
if npub, err := nip19.EncodeEntity(pubkey, kind, d, relays); err == nil {
stdout(npub)
} else {
return err
}
}
exitIfLineProcessingError(c)
return nil
},
},
{
Name: "note",
Usage: "generate note1 event codes (not recommended)",
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c.Args().First()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
lineProcessingError(c, "invalid event id: %s", target)
if pubkey == nostr.ZeroPK {
ctx = lineProcessingError(ctx, "pubkey must be set")
continue
}
if note, err := nip19.EncodeNote(target); err == nil {
stdout(note)
if kind == 0 {
ctx = lineProcessingError(ctx, "kind must be set")
continue
}
if kind.IsAddressable() {
if d == "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier must be set for addressable events")
continue
}
} else if kind.IsReplaceable() {
if d != "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier must not be set for replaceable events")
continue
}
} else {
ctx = lineProcessingError(ctx, "can only encode addressable events")
continue
}
if getBoolInt(c, "outbox") > 0 {
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
relays = nostr.AppendUnique(relays, r)
}
}
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
stdout(nip19.EncodeNaddr(pubkey, nostr.Kind(kind), d, relays))
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},

131
encrypt_decrypt.go Normal file
View File

@@ -0,0 +1,131 @@
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: []cli.Flag{
&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: []cli.Flag{
&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 decrypt: %w", err)
}
stdout(res)
}
return nil
},
}

610
event.go
View File

@@ -2,24 +2,31 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"slices"
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip13"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nson"
"github.com/urfave/cli/v2"
"golang.org/x/exp/slices"
"github.com/urfave/cli/v3"
)
const CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
const (
CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
CATEGORY_SIGNER = "SIGNER OPTIONS"
CATEGORY_EXTRAS = "EXTRAS"
CATEGORY_AUTH = "AUTH"
)
var event = &cli.Command{
var eventCmd = &cli.Command{
Name: "event",
Usage: "generates an encoded event and either prints it or sends it to a set of relays",
Description: `outputs an event built with the flags. if one or more relays are given as arguments, an attempt is also made to publish the event to these relays.
@@ -33,54 +40,95 @@ if an event -- or a partial event -- is given on stdin, the flags can be used to
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'`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as hex or nsec",
DefaultText: "the key '1'",
Value: "0000000000000000000000000000000000000000000000000000000000000001",
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{},
// ~ 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{
Name: "musig",
Usage: "number of signers to use for musig2",
Value: 1,
DefaultText: "1 -- i.e. do not use musig2 at all",
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
&cli.StringSliceFlag{
Name: "musig-pubkey",
Hidden: true,
},
&cli.StringFlag{
Name: "connect",
Usage: "sign event using NIP-46, expects a bunker://... URL",
Name: "musig-nonce-secret",
Hidden: true,
},
&cli.StringSliceFlag{
Name: "musig-nonce",
Hidden: true,
},
&cli.StringSliceFlag{
Name: "musig-partial",
Hidden: true,
},
// ~~~
&cli.BoolFlag{
Name: "force-sign",
Usage: "when an event is already signed and not modified it isn't signed again even when a different --sec is given, this option negates that",
Value: false,
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "no-sign",
Usage: "print the event without signing it, using the specified pubkey",
Value: false,
Category: CATEGORY_SIGNER,
},
&cli.UintFlag{
Name: "pow",
Usage: "nip13 difficulty to target when doing hash work on the event id",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "envelope",
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
Category: CATEGORY_EXTRAS,
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to when communicating with the bunker given on --connect",
DefaultText: "a random key",
Name: "jq",
Usage: "filter returned events with jq expression",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "envelope",
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform NIP-42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Name: "nevent",
Usage: "print the nevent code (to stderr) after the event is published",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "nevent",
Usage: "print the nevent code (to stderr) after the event is published",
Name: "outbox",
Usage: "publish to the \"write\" relays of the author and to the \"read\" relays of anyone mentioned in \"p\" tags",
DefaultText: "false, will only use manually-specified relays",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "nson",
Usage: "encode the event using NSON",
},
&cli.IntFlag{
&KindFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "event kind",
Usage: "event kind number or name",
DefaultText: "1",
Value: 0,
Category: CATEGORY_EVENT_FIELDS,
},
&PubKeyOrAddressFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "set the event pubkey or add an 'a' tag if it's an address",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringFlag{
Name: "content",
Aliases: []string{"c"},
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,
@@ -88,7 +136,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{
@@ -106,98 +154,158 @@ example:
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringFlag{
&cli.StringSliceFlag{
Name: "h",
Usage: "shortcut for --tag h=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&NaturalTimeFlag{
Name: "created-at",
Aliases: []string{"time", "ts"},
Usage: "unix timestamp value for the created_at field",
DefaultText: "now",
Value: "",
Value: nostr.Now(),
Category: CATEGORY_EVENT_FIELDS,
},
},
&cli.BoolFlag{
Name: "confirm",
Usage: "ask before publishing the event",
Category: CATEGORY_EXTRAS,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
),
ArgsUsage: "[relay...]",
Action: func(c *cli.Context) error {
// try to connect to the relays here
var relays []*nostr.Relay
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
_, relays = connectToAllRelays(c.Context, relayUrls)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
}
Action: func(ctx context.Context, c *cli.Command) error {
argRelayUrls := c.Args().Slice()
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(c)
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
doAuth := c.Bool("auth")
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
// then process input and generate events
for stdinEvent := range getStdinLinesOrBlank() {
evt := nostr.Event{
Tags: make(nostr.Tags, 0, 3),
}
// then process input and generate events:
kindWasSupplied := false
// will reuse this
var evt nostr.Event
// this is called when we have a valid json from stdin
handleEvent := func(stdinEvent string) error {
evt.Content = ""
evt.CreatedAt = 0
evt.Tags = evt.Tags[:0]
evt.ID = nostr.ZeroID
evt.PubKey = nostr.ZeroPK
evt.Sig = [64]byte{}
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
contentWasSupplied := strings.Contains(stdinEvent, `"content"`)
mustRehashAndResign := false
if stdinEvent != "" {
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
lineProcessingError(c, "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.Int("kind"); slices.Contains(c.FlagNames(), "kind") {
evt.Kind = kind
if c.IsSet("kind") {
evt.Kind = getKind(c, "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
}
tags := make(nostr.Tags, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
tagFlags := c.StringSlice("tag")
tags := make(nostr.Tags, 0, len(tagFlags)+2)
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, ";")
val := tagValues[0]
if len(tagName) == 1 {
val = decodeTagValue(val, rune(tagName[0]))
}
if len(tagValues) >= 1 {
tagValues[0] = val
}
tag = append(tag, tagValues...)
// ~
tags = tags.AppendUnique(tag)
}
tags = append(tags, tag)
}
for _, etag := range c.StringSlice("e") {
tags = tags.AppendUnique([]string{"e", etag})
mustRehashAndResign = true
decodedEtag := decodeTagValue(etag, 'e')
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})
mustRehashAndResign = true
decodedPtag := decodeTagValue(ptag, 'p')
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})
mustRehashAndResign = true
if tags.FindWithValue("d", dtag) == nil {
tags = append(tags, nostr.Tag{"d", dtag})
}
}
for _, htag := range c.StringSlice("h") {
if tags.FindWithValue("h", htag) == nil {
tags = append(tags, nostr.Tag{"h", htag})
}
}
var authorPubKey nostr.PubKey
for _, a := range getPubKeyOrAddressSlice(c, "author") {
// is it an address?
if a.Addr != nil {
aTag := a.Addr.AsTagReference()
if tags.FindWithValue("a", aTag) == nil {
tags = append(tags, nostr.Tag{"a", aTag})
}
continue
}
// or is it an "author" pubkey?
if a.PubKey != nostr.ZeroPK {
if authorPubKey != nostr.ZeroPK {
return fmt.Errorf("multiple author pubkeys provided")
}
authorPubKey = a.PubKey
}
}
if len(tags) > 0 {
for _, tag := range tags {
@@ -205,100 +313,282 @@ example:
}
mustRehashAndResign = true
}
if authorPubKey != nostr.ZeroPK {
evt.PubKey = authorPubKey
}
if createdAt := c.String("created-at"); createdAt != "" {
ts := time.Now()
if createdAt != "now" {
if v, err := strconv.ParseInt(createdAt, 10, 64); err != nil {
return fmt.Errorf("failed to parse timestamp '%s': %w", createdAt, err)
} else {
ts = time.Unix(v, 0)
}
}
evt.CreatedAt = nostr.Timestamp(ts.Unix())
if c.IsSet("created-at") {
evt.CreatedAt = getNaturalDate(c, "created-at")
mustRehashAndResign = true
} else if evt.CreatedAt == 0 {
evt.CreatedAt = nostr.Now()
mustRehashAndResign = true
}
if evt.Sig == "" || mustRehashAndResign {
if bunker != nil {
if err := bunker.SignEvent(c.Context, &evt); err != nil {
return fmt.Errorf("failed to sign with bunker: %w", err)
if c.IsSet("musig") || c.Bool("force-sign") {
mustRehashAndResign = true
}
if difficulty := c.Uint("pow"); difficulty > 0 {
// before doing pow we need the pubkey
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")
}
evt.PubKey, err = getMusigAggregatedKey(ctx, pubkeys)
if err != nil {
return err
}
} else if evt.PubKey == nostr.ZeroPK {
evt.PubKey, _ = kr.GetPublicKey(ctx)
}
// 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 c.Bool("no-sign") {
if evt.PubKey == nostr.ZeroPK {
return fmt.Errorf("--no-sign requires a pubkey in the event or via --author")
}
evt.ID = nostr.ZeroID
evt.Sig = [64]byte{}
} else if evt.Sig == [64]byte{} || mustRehashAndResign {
if numSigners := c.Uint("musig"); numSigners > 1 {
// must do musig
pubkeys := c.StringSlice("musig-pubkey")
secNonce := c.String("musig-nonce-secret")
pubNonces := c.StringSlice("musig-nonce")
partialSigs := c.StringSlice("musig-partial")
signed, err := performMusig(ctx,
sec, &evt, int(numSigners), pubkeys, pubNonces, secNonce, partialSigs)
if err != nil {
return fmt.Errorf("musig error: %w", err)
}
if !signed {
// we haven't finished signing the event, so the users still have to do more steps
// instructions for what to do should have been printed by the performMusig() function
return nil
}
} else if err := kr.SignEvent(ctx, &evt); err != nil {
if _, isBunker := kr.(keyer.BunkerSigner); isBunker && errors.Is(ctx.Err(), context.DeadlineExceeded) {
err = fmt.Errorf("timeout waiting for bunker to respond")
}
} else if err := evt.Sign(sec); err != nil {
return fmt.Errorf("error signing with provided key: %w", err)
}
}
// print event as json
var result string
if c.Bool("envelope") {
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
result = string(j)
} else if c.Bool("nson") {
result, _ = nson.Marshal(&evt)
} else {
j, _ := easyjson.Marshal(&evt)
result = string(j)
var relays []*nostr.Relay
// start from the given relays on every event so --outbox additions
// for one event don't leak into the next
relayUrls := slices.Clone(argRelayUrls)
if len(relayUrls) > 0 || c.Bool("outbox") {
if c.Bool("outbox") {
if evt.PubKey != nostr.ZeroPK {
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchWriteRelays(ctx, evt.PubKey)...)
}
seenPubkeys := make(map[nostr.PubKey]struct{}, len(evt.Tags))
for _, tag := range evt.Tags {
if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") {
continue
}
pk, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
if _, ok := seenPubkeys[pk]; ok {
continue
}
seenPubkeys[pk] = struct{}{}
relayUrls = nostr.AppendUnique(relayUrls, sys.FetchInboxRelays(ctx, pk, 15)...)
}
}
if len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, c, relayUrls)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
}
}
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(c.Context, 10*time.Second)
defer cancel()
err := relay.Publish(ctx, evt)
if err == nil {
// published fine
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
if strings.HasPrefix(err.Error(), "msg: auth-required:") && (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(c.Context)
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(c.Context, func(evt *nostr.Event) error {
if bunker != nil {
return bunker.SignEvent(c.Context, 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)
// print event as json
if jq == nil {
var result string
if c.Bool("envelope") {
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
result = string(j)
} else {
j, _ := easyjson.Marshal(&evt)
result = string(j)
}
if len(successRelays) > 0 && c.Bool("nevent") {
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
log(nevent + "\n")
stdout(result)
} else {
out, matches, err := jq(evt)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if matches {
stdout(out)
}
}
return publishFlow(ctx, c, kr, evt, relays)
}
for stdinEvent := range getJsonsOrBlank() {
if err := handleEvent(stdinEvent); err != nil {
ctx = lineProcessingError(ctx, err.Error())
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
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 {
_, after, _ := strings.Cut(r.URL, "://")
relaysStr[i] = strings.ToLower(after)
}
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() {
nm := nostr.NormalizeURL(relay.URL)
if r, ok := sys.Pool.Relays.Load(nm); ok && r != nil && r.IsConnected() {
relays[i] = r
relay = r
} else {
ct, cancel := context.WithTimeout(context.Background(), connectTimeout)
new_, err := nostr.RelayConnect(ct, relay.URL, sys.Pool.RelayOptions)
cancel()
if err == nil {
sys.Pool.Relays.Store(nm, new_)
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,31 +0,0 @@
package main
func ExampleEventBasic() {
app.Run([]string{"nak", "event", "--ts", "1699485669"})
// Output:
// {"id":"36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2"}
}
func ExampleEventComplex() {
app.Run([]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:
// {"id":"19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179","pubkey":"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f","created_at":1699485669,"kind":11,"tags":[["t","spam"],["r","https://abc.def?name=foobar","nothing"],["e","36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"]],"content":"skjdbaskd","sig":"cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7"}
}
func ExampleReq() {
app.Run([]string{"nak", "req", "-k", "1", "-l", "18", "-a", "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", "-e", "aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"})
// Output:
// ["REQ","nak",{"kinds":[1],"authors":["2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"],"limit":18,"#e":["aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"]}]
}
func ExampleEncodeNpub() {
app.Run([]string{"nak", "encode", "npub", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
// Output:
// npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28
}
func ExampleEncodeNprofile() {
app.Run([]string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
// Output:
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
}

180
fetch.go
View File

@@ -1,101 +1,151 @@
package main
import (
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
sdk "github.com/nbd-wtf/nostr-sdk"
"github.com/urfave/cli/v2"
"context"
"fmt"
"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 code from the included relay hints",
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`,
Flags: []cli.Flag{
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
},
ArgsUsage: "[nip19code]",
Action: func(c *cli.Context) error {
pool := nostr.NewSimplePool(c.Context)
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
},
&cli.BoolFlag{
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
},
),
ArgsUsage: "[nip05_or_nip19_code]",
Action: func(ctx context.Context, c *cli.Command) error {
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
defer func() {
pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
relay.Close()
return true
})
}()
for code := range getStdinLinesOrFirstArgument(c.Args().First()) {
for code := range getStdinLinesOrArguments(c.Args()) {
filter := nostr.Filter{}
var authorHint nostr.PubKey
relays := c.StringSlice("relay")
prefix, value, err := nip19.Decode(code)
if err != nil {
lineProcessingError(c, "failed to decode: %s", err)
continue
if nip05.IsValidIdentifier(code) {
pp, err := nip05.QueryIdentifier(ctx, code)
if err != nil {
ctx = lineProcessingError(ctx, "failed to fetch nip05: %s", err)
continue
}
authorHint = pp.PublicKey
relays = append(relays, pp.Relays...)
filter.Authors = append(filter.Authors, pp.PublicKey)
} else {
prefix, value, err := nip19.Decode(code)
if err != nil {
ctx = lineProcessingError(ctx, "failed to decode: %s", err)
continue
}
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
switch prefix {
case "nevent":
v := value.(nostr.EventPointer)
filter.IDs = append(filter.IDs, v.ID)
if v.Author != nostr.ZeroPK {
authorHint = v.Author
}
relays = append(relays, v.Relays...)
case "note":
filter.IDs = append(filter.IDs, value.(nostr.EventPointer).ID)
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
relays = append(relays, v.Relays...)
case "nprofile":
v := value.(nostr.ProfilePointer)
filter.Authors = append(filter.Authors, v.PublicKey)
authorHint = v.PublicKey
relays = append(relays, v.Relays...)
case "npub":
v := value.(nostr.PubKey)
filter.Authors = append(filter.Authors, v)
authorHint = v
default:
return fmt.Errorf("unexpected prefix %s", prefix)
}
}
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
if authorHint != nostr.ZeroPK {
for _, url := range relays {
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
}
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 6) {
relays = append(relays, url)
}
}
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
var authorHint string
switch prefix {
case "nevent":
v := value.(nostr.EventPointer)
filter.IDs = append(filter.IDs, v.ID)
if v.Author != "" {
authorHint = v.Author
}
relays = append(relays, v.Relays...)
case "naddr":
v := value.(nostr.EntityPointer)
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
filter.Kinds = append(filter.Kinds, v.Kind)
filter.Authors = append(filter.Authors, v.PublicKey)
authorHint = v.PublicKey
relays = append(relays, v.Relays...)
case "nprofile":
v := value.(nostr.ProfilePointer)
filter.Authors = append(filter.Authors, v.PublicKey)
if len(filter.Authors) > 0 && len(filter.Kinds) == 0 {
filter.Kinds = append(filter.Kinds, 0)
authorHint = v.PublicKey
relays = append(relays, v.Relays...)
case "npub":
v := value.(string)
filter.Authors = append(filter.Authors, v)
filter.Kinds = append(filter.Kinds, 0)
authorHint = v
}
if authorHint != "" {
relayList := sdk.FetchRelaysForPubkey(c.Context, pool, authorHint,
"wss://purplepag.es", "wss://relay.damus.io", "wss://relay.noswhere.com",
"wss://nos.lol", "wss://public.relaying.io", "wss://relay.nostr.band")
for _, relayListItem := range relayList {
if relayListItem.Outbox {
relays = append(relays, relayListItem.URL)
}
}
}
if len(relays) == 0 {
lineProcessingError(c, "no relay hints found")
ctx = lineProcessingError(ctx, "no relay hints found")
continue
}
for ie := range pool.SubManyEose(c.Context, relays, nostr.Filters{filter}) {
stdout(ie.Event)
found := false
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-fetch",
}) {
found = true
var out string
if jq == nil {
out = ie.Event.String()
} else {
v, matches, err := jq(ie.Event)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if !matches {
continue
}
out = v
}
stdout(out)
}
if !found {
ctx = lineProcessingError(ctx, "no events found for %s", code)
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
}

125
filter.go Normal file
View File

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

382
flags.go Normal file
View File

@@ -0,0 +1,382 @@
package main
import (
"errors"
"fmt"
"strconv"
"time"
"fiatjaf.com/nostr"
"github.com/markusmobius/go-dateparser"
"github.com/urfave/cli/v3"
)
type (
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
)
type boolIntValue struct {
int int
defaultWhenSet int
hasDefault bool
hasBeenSet bool
}
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
*p = val
return &boolIntValue{
defaultWhenSet: val,
hasDefault: true,
}
}
func (t boolIntValue) IsBoolFlag() bool {
return true
}
func (t boolIntValue) ToString(b int) string { return "<<>>" }
func (t *boolIntValue) Set(value string) error {
t.hasBeenSet = true
if value == "true" {
if t.hasDefault {
t.int = t.defaultWhenSet
} else {
t.int = 1
}
return nil
} else {
var err error
t.int, err = strconv.Atoi(value)
return err
}
}
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
func (t *boolIntValue) Value() int { return t.int }
func (t *boolIntValue) Get() any { return t.int }
func getBoolInt(cmd *cli.Command, name string) int {
return cmd.Value(name).(int)
}
//
//
//
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
type naturalTimeValue struct {
timestamp *nostr.Timestamp
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.Timestamp, struct{}] = naturalTimeValue{}
func (t naturalTimeValue) Create(val nostr.Timestamp, p *nostr.Timestamp, c struct{}) cli.Value {
*p = val
return &naturalTimeValue{
timestamp: p,
}
}
func (t naturalTimeValue) ToString(b nostr.Timestamp) string {
ts := b.Time()
if ts.IsZero() {
return ""
}
return fmt.Sprintf("%v", ts)
}
func (t *naturalTimeValue) Set(value string) error {
var ts time.Time
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
// when the input is a raw number, treat it as an exact timestamp
ts = time.Unix(n, 0)
} else if errors.Is(err, strconv.ErrRange) {
// this means a huge number, so we should fail
return err
} else {
// otherwise try to parse it as a human date string in natural language
date, err := dateparser.Parse(&dateparser.Configuration{
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, value)
if err != nil {
return err
}
ts = date.Time
}
if t.timestamp != nil {
*t.timestamp = nostr.Timestamp(ts.Unix())
}
t.hasBeenSet = true
return nil
}
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 (
SecretKeyFlag = cli.FlagBase[nostr.SecretKey, struct{}, secretkeyValue]
)
type secretkeyValue struct {
secretkey nostr.SecretKey
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.SecretKey, struct{}] = secretkeyValue{}
func (t secretkeyValue) Create(val nostr.SecretKey, p *nostr.SecretKey, c struct{}) cli.Value {
*p = val
return &secretkeyValue{
secretkey: val,
}
}
func (t secretkeyValue) ToString(b nostr.SecretKey) string { return t.secretkey.String() }
func (t *secretkeyValue) Set(value string) error {
secretkey, err := parseSecretKey(value)
t.secretkey = secretkey
t.hasBeenSet = true
return err
}
func (t *secretkeyValue) String() string { return fmt.Sprintf("%#v", t.secretkey) }
func (t *secretkeyValue) Value() nostr.SecretKey { return t.secretkey }
func (t *secretkeyValue) Get() any { return t.secretkey }
func getSecretKey(cmd *cli.Command, name string) nostr.SecretKey {
return cmd.Value(name).(nostr.SecretKey)
}
//
//
//
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 PubKeyOrAddress struct {
PubKey nostr.PubKey
Addr *nostr.EntityPointer
}
type (
pubKeyOrAddressValue struct {
value PubKeyOrAddress
hasBeenSet bool
}
pubKeyOrAddressSlice = cli.SliceBase[PubKeyOrAddress, struct{}, pubKeyOrAddressValue]
PubKeyOrAddressFlag = cli.FlagBase[[]PubKeyOrAddress, struct{}, pubKeyOrAddressSlice]
)
var _ cli.ValueCreator[PubKeyOrAddress, struct{}] = pubKeyOrAddressValue{}
func (t pubKeyOrAddressValue) Create(val PubKeyOrAddress, p *PubKeyOrAddress, c struct{}) cli.Value {
*p = val
return &pubKeyOrAddressValue{
value: val,
}
}
func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string {
if b.Addr != nil {
return b.Addr.AsTagReference()
}
return b.PubKey.String()
}
func (t *pubKeyOrAddressValue) Set(value string) error {
pubkey, err1 := parsePubKey(value)
if err1 == nil {
t.value = PubKeyOrAddress{PubKey: pubkey}
t.hasBeenSet = true
return nil
}
addr, err2 := nostr.ParseAddrString(value)
if err2 == nil {
t.value = PubKeyOrAddress{Addr: &addr}
t.hasBeenSet = true
return nil
}
return fmt.Errorf("value is neither a pubkey or an address: %w; %w", err1, err2)
}
func (t *pubKeyOrAddressValue) String() string { return fmt.Sprintf("%#v", t.value) }
func (t *pubKeyOrAddressValue) Value() PubKeyOrAddress { return t.value }
func (t *pubKeyOrAddressValue) Get() any { return t.value }
func getPubKeyOrAddressSlice(cmd *cli.Command, name string) []PubKeyOrAddress {
return cmd.Value(name).([]PubKeyOrAddress)
}
//
//
//
type (
IDFlag = cli.FlagBase[nostr.ID, struct{}, idValue]
)
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)
}
//
//
//
type (
KindFlag = cli.FlagBase[nostr.Kind, struct{}, kindValue]
)
type kindValue struct {
kind nostr.Kind
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.Kind, struct{}] = kindValue{}
func (t kindValue) Create(val nostr.Kind, p *nostr.Kind, c struct{}) cli.Value {
*p = val
return &kindValue{
kind: val,
}
}
func (t kindValue) ToString(b nostr.Kind) string { return fmt.Sprintf("%d", b) }
func (t *kindValue) Set(value string) error {
k, err := stringToKind(value)
t.kind = k
t.hasBeenSet = true
return err
}
func (t *kindValue) String() string { return fmt.Sprintf("%#v", t.kind) }
func (t *kindValue) Value() nostr.Kind { return t.kind }
func (t *kindValue) Get() any { return t.kind }
func getKind(cmd *cli.Command, name string) nostr.Kind {
return cmd.Value(name).(nostr.Kind)
}
//
//
//
type (
kindSlice = cli.SliceBase[nostr.Kind, struct{}, kindValue]
KindSliceFlag = cli.FlagBase[[]nostr.Kind, struct{}, kindSlice]
)
func getKindSlice(cmd *cli.Command, name string) []nostr.Kind {
return cmd.Value(name).([]nostr.Kind)
}

123
fs.go Normal file
View File

@@ -0,0 +1,123 @@
//go:build !windows && !openbsd && !cgofuse
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: combineFlags([][]cli.Flag{},
&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
},
}

118
fs_cgo.go Normal file
View File

@@ -0,0 +1,118 @@
//go:build cgofuse && !windows && !openbsd
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: combineFlags([][]cli.Flag{},
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
},
&cli.DurationFlag{
Name: "auto-publish-notes",
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
Value: time.Second * 30,
},
&cli.DurationFlag{
Name: "auto-publish-articles",
Usage: "delay after which edited articles will be auto-published.",
Value: time.Hour * 24 * 365 * 2,
DefaultText: "basically infinite",
},
),
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
mountpoint := c.Args().First()
if mountpoint == "" {
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
}
var kr nostr.User
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
kr = signer
} else {
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
}
apnt := c.Duration("auto-publish-notes")
if apnt < 0 {
apnt = time.Hour * 24 * 365 * 3
}
apat := c.Duration("auto-publish-articles")
if apat < 0 {
apat = time.Hour * 24 * 365 * 3
}
root := nostrfs.NewNostrRoot(
context.WithValue(
context.WithValue(
ctx,
"log", log,
),
"logverbose", logverbose,
),
sys,
kr,
mountpoint,
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
)
// create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint))
// create cgofuse host
host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true)
host.SetUseIno(true)
// mount the filesystem
mountArgs := []string{"-s", mountpoint}
if isVerbose {
mountArgs = append([]string{"-d"}, mountArgs...)
}
go func() {
host.Mount("", mountArgs)
}()
log("ok.\n")
// setup signal handling for clean unmount
ch := make(chan os.Signal, 1)
chErr := make(chan error)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-ch
log("- unmounting... ")
// cgofuse doesn't have explicit unmount, it unmounts on process exit
log("ok\n")
chErr <- nil
}()
// wait for signals
return <-chErr
},
}

20
fs_other.go Normal file
View File

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

140
fs_windows.go Normal file
View File

@@ -0,0 +1,140 @@
//go:build windows
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: combineFlags([][]cli.Flag{},
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
},
&cli.DurationFlag{
Name: "auto-publish-notes",
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
Value: time.Second * 30,
},
&cli.DurationFlag{
Name: "auto-publish-articles",
Usage: "delay after which edited articles will be auto-published.",
Value: time.Hour * 24 * 365 * 2,
DefaultText: "basically infinite",
},
),
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
mountpoint := c.Args().First()
if mountpoint == "" {
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
}
var kr nostr.User
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
kr = signer
} else {
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
}
apnt := c.Duration("auto-publish-notes")
if apnt < 0 {
apnt = time.Hour * 24 * 365 * 3
}
apat := c.Duration("auto-publish-articles")
if apat < 0 {
apat = time.Hour * 24 * 365 * 3
}
root := nostrfs.NewNostrRoot(
context.WithValue(
context.WithValue(
ctx,
"log", log,
),
"logverbose", logverbose,
),
sys,
kr,
mountpoint,
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
)
// create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint))
// create cgofuse host
host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true)
host.SetUseIno(true)
// mount the filesystem - Windows/WinFsp version
// based on rclone cmount implementation
mountArgs := []string{
"-o", "uid=-1",
"-o", "gid=-1",
"--FileSystemName=nak",
}
// check if mountpoint is a drive letter or directory
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
if !isDriveLetter {
// winFsp primarily supports drive letters on Windows
// directory mounting may not work reliably
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
// for directory mounts, follow rclone's approach:
// 1. check that mountpoint doesn't already exist
if _, err := os.Stat(mountpoint); err == nil {
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to check mountpoint: %w", err)
}
// 2. check that parent directory exists
parent := filepath.Join(mountpoint, "..")
if _, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent)
}
return fmt.Errorf("failed to check parent directory: %w", err)
}
// 3. use network mode for directory mounts
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
}
if isVerbose {
mountArgs = append(mountArgs, "-o", "debug")
}
mountArgs = append(mountArgs, mountpoint)
log("ok.\n")
if !host.Mount("", mountArgs) {
return fmt.Errorf("failed to mount filesystem")
}
return nil
},
}

371
gift.go Normal file
View File

@@ -0,0 +1,371 @@
package main
import (
"context"
"fmt"
"math/rand"
"os"
"path/filepath"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip44"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
var gift = &cli.Command{
Name: "gift",
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
Description: `example:
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
{
Name: "wrap",
Flags: []cli.Flag{
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
Required: true,
},
&cli.BoolFlag{
Name: "use-our-identity-key",
Usage: "Encrypt with the key given to --sec directly even when a decoupled key exists for the sender.",
},
&cli.BoolFlag{
Name: "use-their-identity-key",
Usage: "Encrypt to the public key given as --recipient-pubkey directly even when a decoupled key exists for the receiver.",
},
},
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
Description: `example:
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
// get sender pubkey (ourselves)
sender, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get sender pubkey: %w", err)
}
var using bool
var cipher nostr.Cipher = kr
// use decoupled key if it exists
using = false
if !c.Bool("use-our-identity-key") {
configPath := c.String("config-path")
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, sender)
if has {
if err != nil {
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --encrypt-with-our-identity-key to bypass", err)
}
cipher = keyer.NewPlainKeySigner(eSec)
log("- using our decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
using = true
}
}
if !using {
log("- using our identity key %s\n", color.CyanString(sender.Hex()))
}
recipient := getPubKey(c, "recipient-pubkey")
using = false
if !c.Bool("use-their-identity-key") {
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, recipient); exists {
recipient = theirEPub
using = true
log("- using their decoupled encryption public key %s\n", color.CyanString(theirEPub.Hex()))
}
}
if !using {
log("- using their identity public key %s\n", color.CyanString(recipient.Hex()))
}
// read event from stdin
for eventJSON := range getJsonsOrBlank() {
if eventJSON == "{}" {
continue
}
var originalEvent nostr.Event
if err := easyjson.Unmarshal([]byte(eventJSON), &originalEvent); err != nil {
return fmt.Errorf("invalid event JSON: %w", err)
}
// turn into rumor (unsigned event)
rumor := originalEvent
rumor.Sig = [64]byte{} // remove signature
rumor.PubKey = sender
rumor.ID = rumor.GetID() // compute ID
// create seal
rumorJSON, _ := easyjson.Marshal(rumor)
encryptedRumor, err := cipher.Encrypt(ctx, string(rumorJSON), recipient)
if err != nil {
return fmt.Errorf("failed to encrypt rumor: %w", err)
}
seal := &nostr.Event{
Kind: 13,
Content: encryptedRumor,
PubKey: sender,
CreatedAt: randomNow(),
Tags: nostr.Tags{},
}
if err := kr.SignEvent(ctx, seal); err != nil {
return fmt.Errorf("failed to sign seal: %w", err)
}
// create gift wrap
ephemeral := nostr.Generate()
sealJSON, _ := easyjson.Marshal(seal)
convkey, err := nip44.GenerateConversationKey(recipient, ephemeral)
if err != nil {
return fmt.Errorf("failed to generate conversation key: %w", err)
}
encryptedSeal, err := nip44.Encrypt(string(sealJSON), convkey)
if err != nil {
return fmt.Errorf("failed to encrypt seal: %w", err)
}
wrap := &nostr.Event{
Kind: 1059,
Content: encryptedSeal,
CreatedAt: randomNow(),
Tags: nostr.Tags{{"p", recipient.Hex()}},
}
wrap.Sign(ephemeral)
// print the gift-wrap
wrapJSON, err := easyjson.Marshal(wrap)
if err != nil {
return fmt.Errorf("failed to marshal gift wrap: %w", err)
}
stdout(string(wrapJSON))
}
return nil
},
},
{
Name: "unwrap",
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
Description: `example:
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key>`,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
// get receiver public key (ourselves)
receiver, err := kr.GetPublicKey(ctx)
if err != nil {
return err
}
ciphers := []nostr.Cipher{kr}
// use decoupled key if it exists
configPath := c.String("config-path")
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
if has {
if err != nil {
return fmt.Errorf("receiver's decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
}
ciphers = append(ciphers, kr)
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
}
// read gift-wrapped event from stdin
for wrapJSON := range getJsonsOrBlank() {
if wrapJSON == "{}" {
continue
}
var wrap nostr.Event
if err := easyjson.Unmarshal([]byte(wrapJSON), &wrap); err != nil {
return fmt.Errorf("invalid gift wrap JSON: %w", err)
}
if wrap.Kind != 1059 {
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
}
// decrypt seal (in the process also find out if they encrypted it to our identity key or to our decoupled key)
var cipher nostr.Cipher
var seal nostr.Event
// try both the receiver identity key and decoupled key
err = nil
for c, potentialCipher := range ciphers {
switch c {
case 0:
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
case 1:
if eSec.Public() == nostr.ZeroPK {
continue
}
log("- trying receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
}
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
if thisErr != nil {
err = thisErr
continue
}
if thisErr := easyjson.Unmarshal([]byte(sealj), &seal); thisErr != nil {
err = fmt.Errorf("invalid seal JSON: %w", thisErr)
continue
}
cipher = potentialCipher
break
}
if seal.ID == nostr.ZeroID && seal.PubKey == nostr.ZeroPK && seal.CreatedAt == 0 {
// if both ciphers failed above we'll reach here
return fmt.Errorf("failed to decrypt seal: %w", err)
}
if seal.Kind != 13 {
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
}
if !seal.VerifySignature() {
return fmt.Errorf("seal signature is invalid")
}
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
senderEncryptionPublicKeys[0] = theirEPub // put decoupled key first
}
// decrypt rumor (at this point we know what cipher is the one they encrypted to)
// (but we don't know if they have encrypted with their identity key or their decoupled key, so try both)
var rumor nostr.Event
err = nil
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
if senderEncryptionPublicKey == nostr.ZeroPK {
continue
}
switch s {
case 0:
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
case 1:
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
}
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
if thisErr != nil {
err = fmt.Errorf("failed to decrypt rumor: %w", thisErr)
continue
}
if thisErr := easyjson.Unmarshal([]byte(rumorj), &rumor); thisErr != nil {
err = fmt.Errorf("invalid rumor JSON: %w", thisErr)
continue
}
break
}
if rumor.ID == nostr.ZeroID && rumor.PubKey == nostr.ZeroPK && rumor.CreatedAt == 0 {
return fmt.Errorf("failed to decrypt rumor: %w", err)
}
rumor.PubKey = seal.PubKey
rumor.ID = rumor.GetID()
// output the unwrapped event (rumor)
stdout(rumor.String())
}
return nil
},
},
},
}
func randomNow() nostr.Timestamp {
const twoDays = 2 * 24 * 60 * 60
now := time.Now().Unix()
randomOffset := rand.Int63n(twoDays)
return nostr.Timestamp(now - randomOffset)
}
func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) {
relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok {
var ePub nostr.PubKey
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return [32]byte{}, true, fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
}
// check if we have the key
eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex())
if data, err := os.ReadFile(eKeyPath); err == nil {
eSec, err := nostr.SecretKeyFromHex(string(data))
if err != nil {
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key at %s doesn't match the announced key %s", eKeyPath, ePub.Hex())
}
return eSec, true, nil
}
}
return [32]byte{}, false, nil
}
func getDecoupledEncryptionPublicKey(ctx context.Context, pubkey nostr.PubKey) (nostr.PubKey, bool) {
relays := sys.FetchWriteRelays(ctx, pubkey)
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10044},
Authors: []nostr.PubKey{pubkey},
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
if ok {
var ePub nostr.PubKey
// get the pub from the tag
for _, tag := range keyAnnouncementEvent.Tags {
if len(tag) >= 2 && tag[0] == "n" {
ePub, _ = nostr.PubKeyFromHex(tag[1])
break
}
}
if ePub == nostr.ZeroPK {
return nostr.ZeroPK, false
}
return ePub, true
}
return nostr.ZeroPK, false
}

3567
git.go Normal file

File diff suppressed because it is too large Load Diff

128
go.mod
View File

@@ -1,41 +1,115 @@
module github.com/fiatjaf/nak
go 1.21
toolchain go1.21.0
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
github.com/charmbracelet/glamour v0.10.0
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.16.0
github.com/mailru/easyjson v0.7.7
github.com/nbd-wtf/go-nostr v0.28.6
github.com/nbd-wtf/nostr-sdk v0.0.5
github.com/urfave/cli/v2 v2.25.7
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
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/mattn/go-isatty v0.0.20
github.com/mdp/qrterminal/v3 v3.2.1
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.0.0-beta1
github.com/winfsp/cgofuse v1.6.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/sync v0.18.0
golang.org/x/term v0.32.0
)
require (
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.3 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect
fiatjaf.com/lib v0.3.7
github.com/denisbrodbeck/machineid v1.0.1
github.com/hanwen/go-fuse/v2 v2.9.0
github.com/itchyny/gojq v0.12.19
github.com/lithammer/fuzzysearch v1.1.8
github.com/mattn/go-tty/v2 v2.0.0
)
require (
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/PowerDNS/lmdb-go v1.9.3 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bluekeyes/go-gitdiff v0.7.1 // indirect
github.com/btcsuite/btcd v0.24.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/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/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/fiatjaf/eventstore v0.2.16 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/pie/v2 v2.7.0 // indirect
github.com/elnosh/gonuts v0.4.2 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/itchyny/timefmt-go v0.1.8 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/puzpuzpuz/xsync/v3 v3.0.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/gjson v1.17.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/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/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/crypto v0.7.0 // indirect
golang.org/x/sys v0.14.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect
)

324
go.sum
View File

@@ -1,19 +1,53 @@
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e h1:/vgoytiH4qQ28O/oX1hj656X69CnTy1pGj/17HFjH+A=
fiatjaf.com/nostr v0.0.0-20260716191248-c205ed45b97e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg=
github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bluekeyes/go-gitdiff v0.7.1 h1:graP4ElLRshr8ecu0UtqfNTCHrtSyZd3DABQm/DWesQ=
github.com/bluekeyes/go-gitdiff v0.7.1/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.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.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
github.com/btcsuite/btcd/btcec/v2 v2.3.2/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.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
@@ -23,37 +57,76 @@ 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 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/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.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/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3pShXg=
github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og=
github.com/elnosh/gonuts v0.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/eventstore v0.2.16 h1:NR64mnyUT5nJR8Sj2AwJTd1Hqs5kKJcCFO21ggUkvWg=
github.com/fiatjaf/eventstore v0.2.16/go.mod h1:rUc1KhVufVmC+HUOiuPweGAcvG6lEOQCkRCn2Xn5VRA=
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.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU=
github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
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=
@@ -65,24 +138,91 @@ 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/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.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY=
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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/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/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.8.3 h1:IzlyN8BaP4YwUMUDqxOGJhGdZXEDQiAPX43dNPgnzrg=
github.com/mark3labs/mcp-go v0.8.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
github.com/markusmobius/go-dateparser v1.2.3 h1:TvrsIvr5uk+3v6poDjaicnAFJ5IgtFHgLiuMY2Eb7Nw=
github.com/markusmobius/go-dateparser v1.2.3/go.mod h1:cMwQRrBUQlK1UI5TIFHEcvpsMbkWrQLXuaPNMFzuYLk=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nbd-wtf/go-nostr v0.28.6 h1:iOyzk+6ReG0lvyRAar7w7omFmUk5mnXDyFYkJ+zEjiw=
github.com/nbd-wtf/go-nostr v0.28.6/go.mod h1:aFcp8NO3erHg+glzBfh4wpaMrV1/ahcUPAgITdptxwA=
github.com/nbd-wtf/nostr-sdk v0.0.5 h1:rec+FcDizDVO0W25PX0lgYMXvP7zNNOgI3Fu9UCm4BY=
github.com/nbd-wtf/nostr-sdk v0.0.5/go.mod h1:iJJsikesCGLNFZ9dLqhLPDzdt924EagUmdQxT3w2Lmk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-tty/v2 v2.0.0 h1:AgXDfbKcENaiQbjdQ+o2ZusbtiaOhK1ArFD75d5U6qk=
github.com/mattn/go-tty/v2 v2.0.0/go.mod h1:azVwsnH46TUJRQPRp/IrUT+8nRkkvjvkESJJxAA4Y48=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -92,42 +232,99 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
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.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew=
github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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.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/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/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/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
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.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/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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.1.0/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=
@@ -135,14 +332,33 @@ 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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=
@@ -152,9 +368,17 @@ 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=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=

1153
group.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,88 +3,171 @@ package main
import (
"bufio"
"context"
"encoding/hex"
"errors"
"fmt"
"iter"
"math/rand"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
"unicode"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"fiatjaf.com/nostr/sdk"
"github.com/chzyer/readline"
"github.com/fatih/color"
"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"
"github.com/urfave/cli/v2"
jsoniter "github.com/json-iterator/go"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/mattn/go-isatty"
"github.com/mattn/go-tty/v2"
"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(os.Stderr, msg, args...)
}
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...) }
)
var stdout = fmt.Println
var connectTimeout = 2 * time.Second
func isPiped() bool {
stat, _ := os.Stdin.Stat()
return stat.Mode()&os.ModeCharDevice == 0
stat, err := os.Stdin.Stat()
if err != nil {
panic(err)
}
mode := stat.Mode()
is := mode&os.ModeCharDevice == 0
return is
}
func 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) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
// we'll try the next line until we're successful
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) {
stopped = true
return false
}
curr.Reset()
return true
})
if stopped {
return
}
if !hasStdin {
if !yield("{}") {
return
}
}
if finalJsonErr != nil {
log(color.YellowString("stdin json parse error: %s", finalJsonErr))
}
}
}
func getStdinLinesOrFirstArgument(arg string) chan string {
func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
stopped = true
return false
}
return true
})
if stopped {
return
}
if !hasStdin {
if !yield("") {
return
}
}
}
}
func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] {
return getStdinLinesOrArgumentsFromSlice(args.Slice())
}
func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] {
// try the first argument
if arg != "" {
single := make(chan string, 1)
single <- arg
close(single)
return single
if len(args) > 0 {
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), 256*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
}
}
func validateRelayURLs(wsurls []string) error {
for _, wsurl := range wsurls {
func normalizeAndValidateRelayURLs(wsurls []string) error {
for i, wsurl := range wsurls {
wsurl = nostr.NormalizeURL(wsurl)
wsurls[i] = wsurl
u, err := url.Parse(wsurl)
if err != nil {
return fmt.Errorf("invalid relay url '%s': %s", wsurl, err)
@@ -104,132 +187,538 @@ func validateRelayURLs(wsurls []string) error {
func connectToAllRelays(
ctx context.Context,
c *cli.Command,
relayUrls []string,
opts ...nostr.PoolOption,
) (*nostr.SimplePool, []*nostr.Relay) {
relays := make([]*nostr.Relay, 0, len(relayUrls))
pool := nostr.NewSimplePool(ctx, opts...)
for _, url := range relayUrls {
log("connecting to %s... ", url)
if relay, err := pool.EnsureRelay(url); err == nil {
relays = append(relays, relay)
log("ok.\n")
} else {
log(err.Error() + "\n")
) []*nostr.Relay {
// first pass to check if these are valid relay URLs
for i, url := range relayUrls {
url = nostr.NormalizeURL(url)
relayUrls[i] = url
if !nostr.IsValidRelayURL(url) {
log("invalid relay URL: %s\n", url)
os.Exit(4)
}
}
return pool, relays
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))
mu := sync.Mutex{} // guards lines and relays, written from the goroutines below
for i, url := range relayUrls {
lines[i] = make([][]byte, 1, 2)
logthis := func(s string, args ...any) {
mu.Lock()
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
render()
mu.Unlock()
}
colorizepreamble := func(c func(string, ...any) string) {
mu.Lock()
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
mu.Unlock()
}
colorizepreamble(color.CyanString)
go func() {
relay := connectToSingleRelay(ctx, c, url, colorizepreamble, logthis)
if relay != nil {
mu.Lock()
relays = append(relays, relay)
mu.Unlock()
}
wg.Done()
}()
}
wg.Wait()
} else {
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
relay := connectToSingleRelay(ctx, c, url, nil, log)
if relay != nil {
relays = append(relays, relay)
}
log("\n")
}
}
return relays
}
func lineProcessingError(c *cli.Context, msg string, args ...any) {
c.Context = context.WithValue(c.Context, LINE_PROCESSING_ERROR, true)
func connectToSingleRelay(
ctx context.Context,
c *cli.Command,
url string,
colorizepreamble func(c func(string, ...any) string),
logthis func(s string, args ...any),
) *nostr.Relay {
nm := nostr.NormalizeURL(url)
relay, ok := sys.Pool.Relays.Load(nm)
if !ok || relay == nil || !relay.IsConnected() {
connectCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
defer cancel()
var err error
if relay, err = nostr.RelayConnect(connectCtx, url, sys.Pool.RelayOptions); err != nil {
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis(clampError(err, len(url)+12))
return nil
}
sys.Pool.Relays.Store(nm, relay)
go func(r *nostr.Relay, relayURL string) {
<-r.Context().Done()
if current, ok := sys.Pool.Relays.Load(relayURL); ok && current == r {
sys.Pool.Relays.Delete(relayURL)
}
}(relay, nm)
}
if c.Bool("force-pre-auth") {
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 == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return authSigner(ctx, c, logthis, authEvent)
}); err == nil {
// auth succeeded
goto preauthSuccess
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
time.Sleep(time.Second)
continue
} else {
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
}
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 lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
log(msg+"\n", args...)
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
}
func exitIfLineProcessingError(c *cli.Context) {
if val := c.Context.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) {
func exitIfLineProcessingError(ctx context.Context) {
if val := ctx.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) {
os.Exit(123)
}
}
func gatherSecretKeyOrBunkerFromArguments(c *cli.Context) (string, *nip46.BunkerClient, error) {
var err error
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
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(c.Context, clientKey, bunkerURL, nil)
return "", bunker, err
func randString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
sec := c.String("sec")
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(strings.Repeat("0", 64-len(sec)) + 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
return string(b)
}
func promptDecrypt(ncryptsec1 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)
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 askConfirmation(msg string) bool {
if isPiped() {
tty, err := tty.Open()
if err != nil {
return "", err
return false
}
sec, err := nip49.Decrypt(ncryptsec1, password)
defer tty.Close()
log(color.YellowString(msg))
answer, err := tty.ReadString()
if err != nil {
continue
return false
}
return sec, nil
}
return "", fmt.Errorf("couldn't decrypt private key")
}
func ask(msg string, defaultValue string, shouldAskAgain func(answer string) bool) (string, error) {
return _ask(&readline.Config{
Stdout: os.Stderr,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
}, msg, defaultValue, shouldAskAgain)
}
// print newline after password input
fmt.Fprintln(os.Stderr)
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
config := &readline.Config{
Stdout: os.Stderr,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: true,
MaskRune: '*',
}
return _ask(config, msg, "", shouldAskAgain)
}
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: '*',
}
func _ask(config *readline.Config, msg string, defaultValue string, shouldAskAgain func(answer string) bool) (string, error) {
rl, err := readline.NewEx(config)
if err != nil {
return "", err
}
rl, err := readline.NewEx(config)
if err != nil {
return false
}
rl.WriteStdin([]byte(defaultValue))
for {
answer, err := rl.Readline()
if err != nil {
return "", err
return false
}
answer = strings.TrimSpace(strings.ToLower(answer))
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, err
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
}
func parsePubKey(value string) (nostr.PubKey, error) {
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err != nil {
return nostr.ZeroPK, err
}
return pp.PublicKey, nil
}
pk, err := nostr.PubKeyFromHex(value)
if err == nil {
return pk, nil
}
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "npub":
if pk, ok := decoded.(nostr.PubKey); ok {
return pk, nil
}
case "nprofile":
if profile, ok := decoded.(nostr.ProfilePointer); ok {
return profile.PublicKey, nil
}
}
}
return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value)
}
func parseSecretKey(input string) (nostr.SecretKey, error) {
if prefix, ski, err := nip19.Decode(input); err == nil && prefix == "nsec" {
return ski.(nostr.SecretKey), nil
}
sk, err := nostr.SecretKeyFromHex(input)
if err != nil {
return nostr.SecretKey{}, fmt.Errorf("invalid secret key: %w", err)
}
return sk, nil
}
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 event, ok := decoded.(nostr.EventPointer); ok {
return event.ID, nil
}
case "nevent":
if event, ok := decoded.(nostr.EventPointer); ok {
return event.ID, nil
}
}
}
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
}
func decodeTagValue(value string, letter rune) string {
letter = unicode.ToLower(letter)
if letter == 'p' {
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err == nil {
return pp.PublicKey.Hex()
}
}
}
if (letter == 'p' && (strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nprofile1"))) ||
((letter == 'a' || letter == 'q') && strings.HasPrefix(value, "naddr1")) ||
((letter == 'e' || letter == 'q') && (strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1"))) {
if ptr, err := nip19.ToPointer(value); err == nil {
return ptr.AsTagReference()
}
}
return value
}
func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) {
fullpath := filepath.Join(os.TempDir(), filename)
if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}
if wipe {
if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("failed to remove temp file: %w", err)
}
}
tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
if _, err := tmp.WriteString(initialContent); err != nil {
tmp.Close()
return "", fmt.Errorf("failed to write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("failed to close temp file: %w", err)
}
editor := strings.TrimSpace(os.Getenv("VISUAL"))
if editor == "" {
editor = strings.TrimSpace(os.Getenv("EDITOR"))
}
if editor == "" {
editor = "edit"
}
parts := strings.Fields(editor)
if len(parts) == 0 {
return "", fmt.Errorf("failed to parse editor command '%s'", editor)
}
args := append(parts[1:], tmp.Name())
cmd := exec.Command(parts[0], args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("editor command failed: %w", err)
}
data, err := os.ReadFile(tmp.Name())
if err != nil {
return "", fmt.Errorf("failed to read edited temp file: %w", err)
}
return string(data), nil
}
func clampWithEllipsis(s string, size int) string {
if len(s) <= size {
return s
}
return s[0:size-1] + "…"
}
var (
schemaURI string
fetchSchemaOnce sync.Once
schemaCache schema.Schema
schemaErrCache error
)
func getSchema() (schema.Schema, error) {
fetchSchemaOnce.Do(func() {
if strings.HasPrefix(schemaURI, "http") {
schemaCache, schemaErrCache = schema.FetchSchemaFromURL(schemaURI)
} else {
schemaCache, schemaErrCache = schema.NewSchemaFromFile(schemaURI)
}
})
return schemaCache, schemaErrCache
}
func stringToKind(value string) (nostr.Kind, error) {
if n, err := strconv.Atoi(value); err == nil && n >= 0 {
if n > 65535 {
return 0, fmt.Errorf("kind number %d is out of range (0-65535)", n)
}
return nostr.Kind(n), nil
}
// find kind from name
sch, err := getSchema()
if err != nil {
return 0, err
}
fuzzyWords := make([]string, 0, len(sch.Kinds))
fuzzyIndexes := make([]string, 0, len(sch.Kinds))
// exact match
for k, ks := range sch.Kinds {
fuzzyWords = append(fuzzyWords, ks.Description)
fuzzyIndexes = append(fuzzyIndexes, k)
if strings.EqualFold(ks.Description, value) {
return ks.Kind, nil
}
}
// fuzzy match
result := fuzzy.RankFindNormalizedFold(value, fuzzyWords)
bestDesc := "<none>"
bestDist := "-"
if len(result) > 0 {
if bd := result[0].Distance; bd < 26 {
return sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Kind, nil
} else {
bestDesc = sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Description
bestDist = strconv.Itoa(bd)
}
}
return 0, fmt.Errorf("unknown kind: %q (closest: %q, distance: %s)", value, bestDesc, bestDist)
}
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
underline func(...any) string
underlinef func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{
color.New(color.Reset).Print,
color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf,
color.New(color.Underline).Sprint,
color.New(color.Underline).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,
color.New(color.Bold, color.FgHiGreen).Sprintf,
}
func combineFlags(flagSlices [][]cli.Flag, extraFlags ...cli.Flag) []cli.Flag {
total := 0
for _, s := range flagSlices {
total += len(s)
}
total += len(extraFlags)
result := make([]cli.Flag, 0, total)
for _, s := range flagSlices {
result = append(result, s...)
}
result = append(result, extraFlags...)
return result
}

195
helpers_key.go Normal file
View File

@@ -0,0 +1,195 @@
package main
import (
"context"
"crypto/sha256"
"fmt"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip46"
"fiatjaf.com/nostr/nip49"
"github.com/chzyer/readline"
"github.com/denisbrodbeck/machineid"
"github.com/fatih/color"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
)
// the default secret key we will use when "--sec" is not provided
func defaultKey() nostr.SecretKey {
mid, err := machineid.ID()
if err != nil {
k := nostr.SecretKey{}
k[29] = 'n'
k[30] = 'a'
k[31] = 'k'
return k
}
return sha256.Sum256([]byte(mid))
}
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.NewPlainKeySigner(key)
} else {
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
}
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
clientKey := getSecretKey(c, "connect-as")
logverbose("[nip46]: connecting to %s with client key %s\n", bunkerURL, clientKey.Hex())
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, sys.Pool, 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
}
sk, err := parseSecretKey(sec)
return sk, nil, err
}
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\n", colors.errorf(cleanUrl), err)
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth required, but --auth flag not given")
}
var kr nostr.Keyer
if nip46.IsBunkerClientOperation(ctx) {
kr = keyer.NewPlainKeySigner(getSecretKey(c, "connect-as"))
} else {
kr, _, err = gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
}
pk, _ := kr.GetPublicKey(ctx)
log("authenticating as %s... ", color.YellowString("%s…", pk.Hex()[0:16]))
return kr.SignEvent(ctx, authEvent)
}
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
}
}
}

73
install.sh Executable file
View File

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

101
jq.go Normal file
View File

@@ -0,0 +1,101 @@
package main
import (
"fmt"
"fiatjaf.com/nostr"
"github.com/itchyny/gojq"
)
const eventJQPrelude = `
def tags(tagName): .tags | map(select(.[0] == tagName));
def tag(tagName): tags(tagName) | .[0];
def value(tagName): tag(tagName)[1];
def has(tagName): (tags(tagName) | length) > 0;
def hasnt(tagName): (tags(tagName) | length) == 0;
def has_value(tagName; tagValue): tags(tagName) | map(select(.[1] == tagValue)) | length > 0;
def date: .created_at | gmtime | strftime("%Y-%m-%d");
def time: .created_at | gmtime | strftime("%H:%M:%S");
def datetime: .created_at | gmtime | strftime("%Y-%m-%dT%H:%M:%SZ");
`
// jqProcessor runs the compiled jq expression against an event and returns the
// rendered output string. When matches is false the event should be skipped.
type jqProcessor func(nostr.Event) (out string, matches bool, err error)
func jqPrepare(expr string, raw bool) (jqProcessor, error) {
if expr == "" {
return nil, nil
}
query, err := gojq.Parse(eventJQPrelude + expr)
if err != nil {
return nil, fmt.Errorf("invalid jq expression: %w", err)
}
code, err := gojq.Compile(query)
if err != nil {
return nil, fmt.Errorf("failed to compile jq expression: %w", err)
}
return func(evt nostr.Event) (string, bool, error) {
input, err := toJQInput(evt)
if err != nil {
return "", false, err
}
iter := code.Run(input)
for {
v, ok := iter.Next()
if !ok {
return "", false, nil
}
if err, ok := v.(error); ok {
return "", false, err
}
if jqTruthy(v) {
return jqStringify(v, raw), true, nil
}
}
}, nil
}
// jqStringify renders a jq result value. In raw mode string results are printed
// without JSON quoting (like `jq -r`); non-string results are always
// JSON-encoded.
func jqStringify(v any, raw bool) string {
if raw {
if s, ok := v.(string); ok {
return s
}
}
out, _ := json.MarshalToString(v)
return out
}
func toJQInput(v any) (any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("failed to marshal jq input: %w", err)
}
var input any
if err := json.Unmarshal(data, &input); err != nil {
return nil, fmt.Errorf("failed to unmarshal jq input: %w", err)
}
return input, nil
}
func jqTruthy(v any) bool {
switch v := v.(type) {
case nil:
return false
case bool:
return v
default:
return true
}
}

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

404
key.go
View File

@@ -1,61 +1,126 @@
package main
import (
"context"
"encoding/hex"
"fmt"
"strings"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip49"
"github.com/urfave/cli/v2"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip49"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v3"
)
var key = &cli.Command{
Name: "key",
Usage: "operations on secret keys: generate, derive, encrypt, decrypt.",
Description: ``,
Subcommands: []*cli.Command{
Name: "key",
Usage: "operations on secret keys: generate, derive, encrypt, decrypt",
Description: ``,
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
generate,
public,
encrypt,
decrypt,
expand,
encryptKey,
decryptKey,
combine,
validate,
defaultCommand,
},
}
var generate = &cli.Command{
Name: "generate",
Usage: "generates a secret key",
Description: ``,
Action: func(c *cli.Context) error {
sec := nostr.GeneratePrivateKey()
stdout(sec)
Name: "generate",
Usage: "generates a secret key",
Description: ``,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
sec := nostr.Generate()
stdout(sec.Hex())
return nil
},
}
var public = &cli.Command{
Name: "public",
Usage: "computes a public key from a secret key",
Description: ``,
ArgsUsage: "[secret]",
Action: func(c *cli.Context) error {
for sec := range getSecretKeyFromStdinLinesOrFirstArgument(c, c.Args().First()) {
pubkey, err := nostr.GetPublicKey(sec)
Name: "public",
Usage: "computes a public key from a secret key",
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 {
args := c.Args().Slice()
if len(args) == 0 && !isPiped() {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
lineProcessingError(c, "failed to derive public key: %s", err)
continue
return err
}
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get public key: %w", err)
}
stdout(pk.Hex())
return nil
}
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, args) {
_, 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{
Name: "encrypt",
Usage: "encrypts a secret key and prints an ncryptsec code",
Description: `uses the NIP-49 standard.`,
ArgsUsage: "<secret> <password>",
var expand = &cli.Command{
Name: "expand",
Usage: "left-pads a hex key to 64 characters",
Description: ``,
ArgsUsage: "[key]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for keyhex := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
keyhex = strings.TrimSpace(keyhex)
if keyhex == "" {
continue
}
if len(keyhex) > 64 {
ctx = lineProcessingError(ctx, "invalid hex key: length %d", len(keyhex))
continue
}
check := keyhex
if len(check)%2 == 1 {
check = "0" + check
}
if _, err := hex.DecodeString(check); err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
keyhex = strings.ToLower(keyhex)
stdout(strings.Repeat("0", 64-len(keyhex)) + keyhex)
}
return nil
},
}
var encryptKey = &cli.Command{
Name: "encrypt",
Usage: "encrypts a secret key and prints an ncryptsec code",
Description: `uses the nip49 standard.`,
ArgsUsage: "<secret> <password>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "logn",
@@ -64,24 +129,23 @@ var encrypt = &cli.Command{
DefaultText: "16",
},
},
Action: func(c *cli.Context) error {
var content string
Action: func(ctx context.Context, c *cli.Command) error {
keys := make([]string, 0, 1)
var password string
switch c.Args().Len() {
case 1:
content = ""
password = c.Args().Get(0)
case 2:
content = c.Args().Get(0)
keys = append(keys, c.Args().Get(0))
password = c.Args().Get(1)
}
if password == "" {
return fmt.Errorf("no password given")
}
for sec := range getSecretKeyFromStdinLinesOrFirstArgument(c, content) {
for sec := range getSecretKeysFromStdinLinesOrSlice(ctx, c, keys) {
ncryptsec, err := nip49.Encrypt(sec, password, uint8(c.Int("logn")), 0x02)
if err != nil {
lineProcessingError(c, "failed to encrypt: %s", err)
ctx = lineProcessingError(ctx, "failed to encrypt: %s", err)
continue
}
stdout(ncryptsec)
@@ -90,58 +154,264 @@ var encrypt = &cli.Command{
},
}
var decrypt = &cli.Command{
Name: "decrypt",
Usage: "takes an ncrypsec and a password and decrypts it into an nsec",
Description: `uses the NIP-49 standard.`,
ArgsUsage: "<ncryptsec-code> <password>",
Action: func(c *cli.Context) error {
var content string
var decryptKey = &cli.Command{
Name: "decrypt",
Usage: "takes an ncrypsec and a password and decrypts it into an nsec",
Description: `uses the nip49 standard.`,
ArgsUsage: "<ncryptsec-code> <password>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
var ncryptsec string
var password string
switch c.Args().Len() {
case 1:
content = ""
password = c.Args().Get(0)
case 2:
content = c.Args().Get(0)
ncryptsec = c.Args().Get(0)
password = c.Args().Get(1)
}
if password == "" {
return fmt.Errorf("no password given")
}
for ncryptsec := range getStdinLinesOrFirstArgument(content) {
sec, err := nip49.Decrypt(ncryptsec, password)
if password == "" {
return fmt.Errorf("no password given")
}
sk, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
lineProcessingError(c, "failed to decrypt: %s", err)
return fmt.Errorf("failed to decrypt: %s", err)
}
stdout(sk.Hex())
return nil
case 1:
if arg := c.Args().Get(0); strings.HasPrefix(arg, "ncryptsec1") {
ncryptsec = arg
if sk, err := promptDecrypt(ncryptsec); err != nil {
return err
} else {
stdout(sk.Hex())
return nil
}
} else {
password = c.Args().Get(0)
// the ncryptsec codes come from stdin
for ncryptsec := range getStdinLinesOrArgumentsFromSlice(nil) {
sk, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
continue
}
stdout(sk.Hex())
}
exitIfLineProcessingError(ctx)
return nil
}
default:
return fmt.Errorf("invalid number of arguments")
}
},
}
var validate = &cli.Command{
Name: "validate",
Usage: "validates a public key by attempting to parse it",
Description: `Accepts public keys in hex (64 characters) or npub format.
Returns error if key is invalid, otherwise exits successfully.`,
ArgsUsage: "[pubkey...]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for pk := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
pk = strings.TrimSpace(pk)
if pk == "" {
continue
}
var pkBytes []byte
var err error
if strings.HasPrefix(pk, "npub1") {
_, data, err := nip19.Decode(pk)
if err != nil {
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
continue
}
tmp := data.(nostr.PubKey)
pkBytes = tmp[:]
} else {
pkBytes, err = hex.DecodeString(pk)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex: %s", err)
continue
}
if len(pkBytes) != 32 {
ctx = lineProcessingError(ctx, "invalid pubkey length: expected 32 bytes, got %d", len(pkBytes))
continue
}
}
_, err = btcec.ParsePubKey(append([]byte{0x02}, pkBytes...))
if err != nil {
ctx = lineProcessingError(ctx, "invalid pubkey: %s", err)
continue
}
nsec, _ := nip19.EncodePrivateKey(sec)
stdout(nsec)
}
return nil
},
}
func getSecretKeyFromStdinLinesOrFirstArgument(c *cli.Context, content string) chan string {
ch := make(chan string)
var defaultCommand = &cli.Command{
Name: "default",
Usage: "prints the default secret key",
Description: `the default secret key is generated differently for each machine (or falls back to a hardcoded one in case of failure), it is not save in any way, but this command makes it available if that is needed.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
stdout(nip19.EncodeNsec(defaultKey()))
return nil
},
}
var combine = &cli.Command{
Name: "combine",
Usage: "combines two or more pubkeys using musig2",
Description: `The public keys must have 33 bytes (66 characters hex), with the 02 or 03 prefix. It is common in Nostr to drop that first byte, so you'll have to derive the public keys again from the private keys in order to get it back.
However, if the intent is to check if two existing Nostr pubkeys match a given combined pubkey, then it might be sufficient to calculate the combined key for all the possible combinations of pubkeys in the input.`,
ArgsUsage: "[pubkey...]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
type Combination struct {
Variants []string `json:"input_variants"`
Output struct {
XOnly string `json:"x_only"`
Variant string `json:"variant"`
} `json:"combined_key"`
}
type Result struct {
Keys []string `json:"keys"`
Combinations []Combination `json:"combinations"`
}
result := Result{}
result.Keys = c.Args().Slice()
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
for i, keyhex := range result.Keys {
pk32, err := parsePubKey(keyhex)
if err == nil { /* we'll try both the 02 and the 03 prefix versions */
result.Keys[i] = pk32.Hex()
group := make([]*btcec.PublicKey, 2)
for i, prefix := range []byte{0x02, 0x03} {
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, pk32[:]...))
if err != nil {
log("error parsing key %s: %s", keyhex, err)
continue
}
group[i] = pubk
}
keyGroups = append(keyGroups, group)
continue
}
keyb, err := hex.DecodeString(keyhex)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
if len(keyb) == 33 {
result.Keys[i] = hex.EncodeToString(keyb[1:])
pubk, err := btcec.ParsePubKey(keyb)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
keyGroups = append(keyGroups, []*btcec.PublicKey{pubk})
// remove the leading byte from the output just so it is all uniform
result.Keys[i] = result.Keys[i][2:]
continue
}
return fmt.Errorf("invalid key %s: expected 32 or 33 bytes hex", keyhex)
}
if len(keyGroups) < 2 {
return fmt.Errorf("at least two pubkeys are required")
}
result.Combinations = make([]Combination, 0, 16)
var fn func(prepend int, curr []int)
fn = func(prepend int, curr []int) {
curr = append([]int{prepend}, curr...)
if len(curr) == len(keyGroups) {
combi := Combination{
Variants: make([]string, len(keyGroups)),
}
combining := make([]*btcec.PublicKey, len(keyGroups))
for g, altKeys := range keyGroups {
altKey := altKeys[curr[g]]
variant := secp256k1.PubKeyFormatCompressedEven
if altKey.Y().Bit(0) == 1 {
variant = secp256k1.PubKeyFormatCompressedOdd
}
combi.Variants[g] = hex.EncodeToString([]byte{variant})
combining[g] = altKey
}
agg, _, _, err := musig2.AggregateKeys(combining, true)
if err != nil {
log("error aggregating: %s", err)
return
}
serialized := agg.FinalKey.SerializeCompressed()
combi.Output.XOnly = hex.EncodeToString(serialized[1:])
combi.Output.Variant = hex.EncodeToString(serialized[0:1])
result.Combinations = append(result.Combinations, combi)
return
}
fn(0, curr)
if len(keyGroups[len(keyGroups)-len(curr)-1]) > 1 {
fn(1, curr)
}
}
fn(0, nil)
if len(keyGroups[len(keyGroups)-1]) > 1 {
fn(1, nil)
}
res, _ := json.MarshalIndent(result, "", " ")
stdout(string(res))
return nil
},
}
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan nostr.SecretKey {
ch := make(chan nostr.SecretKey)
go func() {
for sec := range getStdinLinesOrFirstArgument(content) {
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 {
lineProcessingError(c, "invalid nsec code: %s", err)
ctx = lineProcessingError(ctx, "invalid nsec code: %s", err)
continue
}
sk = data.(nostr.SecretKey)
} else {
var err error
sk, err = nostr.SecretKeyFromHex(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
sec = data.(string)
}
if !nostr.IsValid32ByteHex(sec) {
lineProcessingError(c, "invalid hex secret key")
continue
}
ch <- sec
ch <- sk
}
close(ch)
}()

64
kind.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"context"
"fmt"
"strconv"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
var kindCmd = &cli.Command{
Name: "kind",
Usage: "look up information about a Nostr event kind",
Description: `takes a kind number or a kind name and prints information about that kind from the registry of kinds schema.
example:
nak kind 1
nak kind "text note"
nak kind "git meta"
nak kind "fav relays"
nak kind 30023`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("requires exactly one argument: kind number or name")
}
input := c.Args().First()
// resolve input to a kind number
var k nostr.Kind
if n, err := strconv.ParseUint(input, 10, 16); err == nil && n >= 0 {
k = nostr.Kind(n)
} else {
resolved, err := stringToKind(input)
if err != nil {
return fmt.Errorf("failed to resolve kind: %w", err)
}
k = resolved
}
sch, err := getSchema()
if err != nil {
return err
}
ks := sch.Kinds[strconv.Itoa(int(k.Num()))]
j, _ := json.MarshalIndent(ks, "", " ")
stdout(string(j))
return nil
},
}

46
lmdb.go Normal file
View File

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

218
main.go
View File

@@ -1,52 +1,222 @@
package main
import (
"context"
"net/http"
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
"github.com/urfave/cli/v2"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var q int
var authFlags = []cli.Flag{
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_AUTH,
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, wait for a nip42 \"AUTH\" message to be received, act on it and only then send the query",
Category: CATEGORY_AUTH,
},
}
var app = &cli.App{
Name: "nak",
Suggest: true,
UseShortOptionHandling: true,
Usage: "the nostr army knife command-line tool",
var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: defaultKey().Hex(),
DefaultText: "a default key specific to your machine, see it with `nak key default`",
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Category: CATEGORY_SIGNER,
Hidden: true,
},
&SecretKeyFlag{
Name: "connect-as",
Usage: "private key to use when communicating with nip46 bunkers",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
Value: defaultKey(),
DefaultText: "the default key (see `nak key default`)",
Hidden: true,
},
}
var (
version string = "debug"
isVerbose bool = false
)
var app = &cli.Command{
Name: "nak",
Suggest: true,
UseShortOptionHandling: true,
Usage: "the nostr army knife command-line tool",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
eventCmd,
req,
count,
filterCmd,
fetch,
event,
count,
decode,
encode,
key,
kindCmd,
verify,
relay,
admin,
bunker,
serve,
blossomCmd,
dekey,
encrypt,
decrypt,
gift,
outboxCmd,
wallet,
mcpServer,
curl,
fsCmd,
publish,
nsite,
git,
group,
podcast,
nip,
syncCmd,
spell,
profile,
validateCmd,
},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "quiet",
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
Count: &q,
Aliases: []string{"q"},
Action: func(ctx *cli.Context, b bool) error {
if q >= 1 {
log = func(msg string, args ...any) {}
if q >= 2 {
stdout = func(a ...any) (int, error) { return 0, nil }
Version: version,
Flags: combineFlags([][]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 ""
}
}
return nil
})(),
},
&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"},
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) {}
}
}
return nil
},
Hidden: true,
},
&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
},
Hidden: true,
},
&cli.DurationFlag{
Name: "connect-timeout",
Usage: "timeout for connecting to relays",
Value: connectTimeout,
Action: func(ctx context.Context, c *cli.Command, d time.Duration) error {
connectTimeout = d
return nil
},
Hidden: true,
},
},
defaultKeyFlags,
authFlags,
}),
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
sys = sdk.NewSystem()
setupLocalDatabases(c, sys)
sys.Pool.QueryMiddleware = sys.TrackQueryAttempts
sys.Pool.EventMiddleware = sys.TrackEventHints
sys.Pool.RelayOptions = nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
return ctx, nil
},
}
func init() {
cli.VersionFlag = &cli.BoolFlag{
Name: "version",
Usage: "prints the version",
}
cli.HelpFlag = &cli.BoolFlag{
Name: "help",
Usage: "shows help",
HideDefault: true,
Local: true,
}
}
func main() {
if err := app.Run(os.Args); err != nil {
stdout(err)
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 {
log(color.YellowString(err.Error()) + "\n")
colors.reset()
os.Exit(1)
}
return
}
if err := app.Run(context.Background(), os.Args); err != nil {
log("%s\n", color.RedString(err.Error()))
colors.reset()
os.Exit(1)
}
}

352
mcp.go Normal file
View File

@@ -0,0 +1,352 @@
package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"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: []cli.Flag{},
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")
if limit == 0 {
limit = 10
}
res := strings.Builder{}
res.Grow(500)
res.WriteString("search results: ")
// check if input is already a valid pubkey
if pubkey, err := nostr.PubKeyFromHex(name); err == nil {
pm := sys.FetchProfileMetadata(ctx, pubkey)
res.WriteString(fmt.Sprintf("\n\nResult 1\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
pm.ShortName(), pm.PubKey.Hex(), pm.About))
} else {
// otherwise try to search
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
if limit > 0 {
filter.Limit = int(limit)
}
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine", "search.nos.social"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
pm, _ := sdk.ParseMetadata(result.Event)
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
}
}
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("\ncontent: '")
result.WriteString(ie.Content)
result.WriteString("'")
result.WriteString("\n---\n")
}
return mcp.NewToolResultText(result.String()), nil
})
s.AddTool(mcp.NewTool("search_events",
mcp.WithDescription("search for Nostr events. specifying the author makes it so we'll try to use their relays instead of generic ones."),
mcp.WithString("search", mcp.Description("search query string"), mcp.Required()),
mcp.WithString("author", mcp.Description("author public key to filter by")),
mcp.WithNumber("kind", mcp.Description("event kind to filter by")),
mcp.WithNumber("limit", mcp.Description("maximum number of results to return")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
search := required[string](r, "search")
author, hasAuthor := optional[string](r, "author")
kind, hasKind := optional[float64](r, "kind")
limit, _ := optional[float64](r, "limit")
if limit == 0 {
limit = 50
}
filter := nostr.Filter{Search: search, Limit: int(limit)}
if hasKind {
filter.Kinds = []nostr.Kind{nostr.Kind(int(kind))}
}
var relays []string
if hasAuthor {
if pk, err := nostr.PubKeyFromHex(author); err != nil {
return mcp.NewToolResultError("the author given isn't a valid public key, it must be 32 bytes hex. Got error: " + err.Error()), nil
} else {
filter.Authors = append(filter.Authors, pk)
}
pk, _ := nostr.PubKeyFromHex(author)
writeRelays := sys.FetchOutboxRelays(ctx, pk, 5)
for _, relayURL := range writeRelays {
if info, err := nip11.Fetch(ctx, relayURL); err == nil {
for _, nip := range info.SupportedNIPs {
if nipInt, ok := nip.(float64); ok && nipInt == 50 {
relays = append(relays, relayURL)
break
}
}
}
}
}
if len(relays) == 0 {
relays = []string{"relay.nostr.band", "nostr.polyserv.xyz/", "search.nos.today/"}
}
result := strings.Builder{}
result.WriteString(fmt.Sprintf("search results for '%s':\n\n", search))
l := 0
for event := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
result.WriteString(fmt.Sprintf("result %d\nID: %s\nKind: %d\nAuthor: %s\nContent: %s\n---\n",
l, event.ID, event.Kind, event.PubKey.Hex(), event.Content))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("no events found matching the search criteria."), nil
}
return mcp.NewToolResultText(result.String()), nil
})
return server.ServeStdio(s)
},
}
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
}

347
musig2.go Normal file
View File

@@ -0,0 +1,347 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
"fiatjaf.com/nostr"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
)
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 nostr.ZeroPK, fmt.Errorf("'%s' is invalid hex: %w", spk, err)
}
if len(bpk) == 32 {
return nostr.ZeroPK, fmt.Errorf("'%s' is missing the leading parity byte", spk)
}
pk, err := btcec.ParsePubKey(bpk)
if err != nil {
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 nostr.ZeroPK, fmt.Errorf("aggregation failed: %w", err)
}
return nostr.PubKey(aggpk.FinalKey.SerializeCompressed()[1:]), nil
}
func performMusig(
_ context.Context,
sec nostr.SecretKey,
evt *nostr.Event,
numSigners int,
keys []string,
nonces []string,
secNonce string,
partialSigs []string,
) (signed bool, err error) {
// preprocess data received
seck, pubk := btcec.PrivKeyFromBytes(sec[:])
knownSigners := make([]*btcec.PublicKey, 0, numSigners)
includesUs := false
for _, hexpub := range keys {
bpub, err := hex.DecodeString(hexpub)
if err != nil {
return false, err
}
spub, err := btcec.ParsePubKey(bpub)
if err != nil {
return false, err
}
knownSigners = append(knownSigners, spub)
if spub.IsEqual(pubk) {
includesUs = true
}
}
if !includesUs {
knownSigners = append(knownSigners, pubk)
}
knownNonces := make([][66]byte, 0, numSigners)
for _, hexnonce := range nonces {
bnonce, err := hex.DecodeString(hexnonce)
if err != nil {
return false, err
}
if len(bnonce) != 66 {
return false, fmt.Errorf("nonce is not 66 bytes: %s", hexnonce)
}
var b66nonce [66]byte
copy(b66nonce[:], bnonce)
knownNonces = append(knownNonces, b66nonce)
}
knownPartialSigs := make([]*musig2.PartialSignature, 0, numSigners)
for _, hexps := range partialSigs {
bps, err := hex.DecodeString(hexps)
if err != nil {
return false, err
}
var ps musig2.PartialSignature
if err := ps.Decode(bytes.NewBuffer(bps)); err != nil {
return false, fmt.Errorf("invalid partial signature %s: %w", hexps, err)
}
knownPartialSigs = append(knownPartialSigs, &ps)
}
// create the context
var mctx *musig2.Context
if len(knownSigners) < numSigners {
// we don't know all the signers yet
mctx, err = musig2.NewContext(seck, true,
musig2.WithNumSigners(numSigners),
musig2.WithEarlyNonceGen(),
)
if err != nil {
return false, fmt.Errorf("failed to create signing context with %d unknown signers: %w",
numSigners, err)
}
} else {
// we know all the signers
mctx, err = musig2.NewContext(seck, true,
musig2.WithKnownSigners(knownSigners),
)
if err != nil {
return false, fmt.Errorf("failed to create signing context with %d known signers: %w",
len(knownSigners), err)
}
}
// nonce generation phase -- for sharing
if len(knownSigners) < numSigners {
// if we don't have all the signers we just generate a nonce and yield it to the next people
nonce, err := mctx.EarlySessionNonce()
if err != nil {
return false, err
}
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)
return false, nil
}
// if we got here we have all the pubkeys, so we can print the combined key
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 = nostr.PubKey(comb.SerializeCompressed()[1:])
evt.ID = evt.GetID()
log("combined key: %x\n\n", comb.SerializeCompressed())
}
// we have all the signers, which means we must also have all the nonces
var session *musig2.Session
if len(keys) == numSigners-1 {
// if we were the last to include our key, that means we have to include our nonce here to
// i.e. we didn't input our own pub nonce in the parameters
session, err = mctx.NewSession()
if err != nil {
return false, fmt.Errorf("failed to create session as the last peer to include our key: %w", err)
}
knownNonces = append(knownNonces, session.PublicNonce())
} else {
// otherwise we have included our own nonce in the parameters (from copypasting) but must
// also include the secret nonce that wasn't shared with peers
if secNonce == "" {
return false, fmt.Errorf("missing --musig-nonce-secret value")
}
secNonceB, err := base64.StdEncoding.DecodeString(secNonce)
if err != nil {
return false, fmt.Errorf("invalid --musig-nonce-secret: %w", err)
}
var secNonce97 [97]byte
copy(secNonce97[:], secNonceB)
session, err = mctx.NewSession(musig2.WithPreGeneratedNonce(&musig2.Nonces{
SecNonce: secNonce97,
PubNonce: secNonceToPubNonce(secNonce97),
}))
if err != nil {
return false, fmt.Errorf("failed to create signing session with secret nonce: %w", err)
}
}
var noncesOk bool
for _, b66nonce := range knownNonces {
if b66nonce == session.PublicNonce() {
// don't add our own nonce
continue
}
noncesOk, err = session.RegisterPubNonce(b66nonce)
if err != nil {
return false, fmt.Errorf("failed to register nonce: %w", err)
}
}
if !noncesOk {
return false, fmt.Errorf("we've registered all the nonces we had but at least one is missing, this shouldn't happen")
}
// signing phase
// we always have to sign, so let's do this
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)
}
if len(knownPartialSigs)+1 < len(knownSigners) {
// still missing some signatures
knownPartialSigs = append(knownPartialSigs, partialSig) // we include ours here just so it's printed
printPublicCommandForNextPeer(evt, numSigners, knownSigners, knownNonces, knownPartialSigs, true)
return false, nil
} else {
// we have all signatures
for _, ps := range knownPartialSigs {
_, err = session.CombineSig(ps)
if err != nil {
return false, fmt.Errorf("failed to combine partial signature: %w", err)
}
}
}
// we have the signature
evt.Sig = [64]byte(session.FinalSig().Serialize())
return true, nil
}
func printPublicCommandForNextPeer(
evt *nostr.Event,
numSigners int,
knownSigners []*btcec.PublicKey,
knownNonces [][66]byte,
knownPartialSigs []*musig2.PartialSignature,
includeNonceSecret bool,
) {
maybeNonceSecret := ""
if includeNonceSecret {
maybeNonceSecret = " --musig-nonce-secret '<insert-nonce-secret>'"
}
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),
noncesToCliArgs(knownNonces),
partialSigsToCliArgs(knownPartialSigs),
maybeNonceSecret,
)
}
func eventToCliArgs(evt *nostr.Event) string {
b := strings.Builder{}
b.Grow(100)
b.WriteString("-k ")
b.WriteString(strconv.Itoa(int(evt.Kind)))
b.WriteString(" -ts ")
b.WriteString(strconv.FormatInt(int64(evt.CreatedAt), 10))
b.WriteString(" -c '")
b.WriteString(evt.Content)
b.WriteString("'")
for _, tag := range evt.Tags {
b.WriteString(" -t '")
b.WriteString(tag[0])
if len(tag) > 1 {
b.WriteString("=")
b.WriteString(tag[1])
if len(tag) > 2 {
for _, item := range tag[2:] {
b.WriteString(";")
b.WriteString(item)
}
}
}
b.WriteString("'")
}
return b.String()
}
func signersToCliArgs(knownSigners []*btcec.PublicKey) string {
b := strings.Builder{}
b.Grow(len(knownSigners) * (16 + 66))
for _, signerPub := range knownSigners {
b.WriteString(" --musig-pubkey ")
b.WriteString(hex.EncodeToString(signerPub.SerializeCompressed()))
}
return b.String()
}
func noncesToCliArgs(knownNonces [][66]byte) string {
b := strings.Builder{}
b.Grow(len(knownNonces) * (15 + 132))
for _, nonce := range knownNonces {
b.WriteString(" --musig-nonce ")
b.WriteString(hex.EncodeToString(nonce[:]))
}
return b.String()
}
func partialSigsToCliArgs(knownPartialSigs []*musig2.PartialSignature) string {
b := strings.Builder{}
b.Grow(len(knownPartialSigs) * (17 + 64))
for _, partialSig := range knownPartialSigs {
b.WriteString(" --musig-partial ")
w := &bytes.Buffer{}
partialSig.Encode(w)
b.Write([]byte(hex.EncodeToString(w.Bytes())))
}
return b.String()
}
// this function is copied from btcec because it's not exported for some reason
func secNonceToPubNonce(secNonce [musig2.SecNonceSize]byte) [musig2.PubNonceSize]byte {
var k1Mod, k2Mod btcec.ModNScalar
k1Mod.SetByteSlice(secNonce[:btcec.PrivKeyBytesLen])
k2Mod.SetByteSlice(secNonce[btcec.PrivKeyBytesLen:])
var r1, r2 btcec.JacobianPoint
btcec.ScalarBaseMultNonConst(&k1Mod, &r1)
btcec.ScalarBaseMultNonConst(&k2Mod, &r2)
// Next, we'll convert the key in jacobian format to a normal public
// key expressed in affine coordinates.
r1.ToAffine()
r2.ToAffine()
r1Pub := btcec.NewPublicKey(&r1.X, &r1.Y)
r2Pub := btcec.NewPublicKey(&r2.X, &r2.Y)
var pubNonce [musig2.PubNonceSize]byte
// The public nonces are serialized as: R1 || R2, where both keys are
// serialized in compressed format.
copy(pubNonce[:], r1Pub.SerializeCompressed())
copy(
pubNonce[btcec.PubKeyBytesLenCompressed:],
r2Pub.SerializeCompressed(),
)
return pubNonce
}

213
nip.go Normal file
View File

@@ -0,0 +1,213 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/charmbracelet/glamour"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
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
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
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width < 80 {
width = 120
}
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(width),
)
if err != nil {
return fmt.Errorf("failed to create renderer: %w", err)
}
rendered, err := r.Render(string(body))
if err != nil {
return fmt.Errorf("failed to render markdown: %w", err)
}
stdout(rendered)
return nil
},
}
func listnips() <-chan nipInfo {
ch := make(chan nipInfo)
go func() {
defer close(ch)
resp, err := http.Get("https://raw.githubusercontent.com/nostr-protocol/nips/master/README.md")
if err != nil {
// TODO: handle error? but since chan, maybe send error somehow, but for now, just close
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
bodyStr := string(body)
epoch := strings.Index(bodyStr, "## List")
if epoch == -1 {
return
}
lines := strings.SplitSeq(bodyStr[epoch+8:], "\n")
for line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "##") {
break
}
if !strings.HasPrefix(line, "- [NIP-") {
continue
}
start := strings.Index(line, "[")
end := strings.Index(line, "]")
if start == -1 || end == -1 || end < start {
continue
}
content := line[start+1 : end]
parts := strings.SplitN(content, ":", 2)
if len(parts) != 2 {
continue
}
nipPart := parts[0]
descPart := parts[1]
rest := line[end+1:]
linkStart := strings.Index(rest, "(")
linkEnd := strings.Index(rest, ")")
link := ""
if linkStart != -1 && linkEnd != -1 && linkEnd > linkStart {
link = rest[linkStart+1 : linkEnd]
}
ch <- nipInfo{nipPart, strings.TrimSpace(descPart), link}
}
}()
return ch
}

11
non_lmdb.go Normal file
View File

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

56
nostrfs/asyncfile.go Normal file
View File

@@ -0,0 +1,56 @@
package nostrfs
import (
"context"
"sync/atomic"
"syscall"
"fiatjaf.com/nostr"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
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
}

1180
nostrfs_cgo/root.go Normal file

File diff suppressed because it is too large Load Diff

338
nsite.go Normal file
View File

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

38
outbox.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
)
var outboxCmd = &cli.Command{
Name: "outbox",
Usage: "manage outbox relay hints database",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
{
Name: "list",
Usage: "list outbox relays for a given pubkey",
ArgsUsage: "<pubkey>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
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
},
},
},
}

625
podcast.go Normal file
View File

@@ -0,0 +1,625 @@
package main
import (
"context"
"fmt"
"os/exec"
"runtime"
"slices"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
type podcastInfo struct {
PubKey nostr.PubKey
Title string
Image string
Relays []string
Metadata nostr.Event
}
var podcast = &cli.Command{
Name: "podcast",
Usage: "play podcasts from Nostr",
Commands: []*cli.Command{
{
Name: "play",
Usage: "play latest episode from podcast or specific episode",
ArgsUsage: "<pubkey|nip05|npub|nprofile|nevent>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.StringFlag{
Name: "player",
Usage: "player command to use, defaults to auto-detect",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast or episode target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
episode, err := resolvePodcastEpisode(ctx, target, relays)
if err != nil {
return err
}
if err := playPodcastEpisode(c.String("player"), episode); err != nil {
return err
}
}
return nil
},
},
{
Name: "info",
Usage: "display podcast metadata",
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
if err != nil {
return err
}
for _, p := range podcasts {
printPodcastInfo(p)
}
}
return nil
},
},
{
Name: "list",
Usage: "list podcast episodes",
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "maximum number of episodes to fetch",
Value: 50,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
if err != nil {
return err
}
for _, p := range podcasts {
if err := printPodcastEpisodeList(ctx, p, int(c.Int("limit"))); err != nil {
return err
}
}
}
return nil
},
},
},
}
func resolvePodcastEpisode(ctx context.Context, target string, relayHints []string) (*nostr.Event, error) {
target = strings.TrimSpace(strings.TrimPrefix(target, "nostr:"))
if prefix, value, err := nip19.Decode(target); err == nil {
switch prefix {
case "nevent":
pointer := value.(nostr.EventPointer)
return fetchSpecificPodcastEpisode(ctx, pointer, relayHints)
case "note":
return fetchSpecificPodcastEpisode(ctx, value.(nostr.EventPointer), relayHints)
}
}
if pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target); err == nil {
return fetchLatestEpisodeForPodcastSelection(ctx, pk, nostr.AppendUnique(relayHints, extraRelays...))
}
return nil, fmt.Errorf("invalid podcast target %q", target)
}
func resolvePodcastPubKeyTarget(ctx context.Context, target string) (nostr.PubKey, []string, error) {
if strings.TrimSpace(target) == "" {
return nostr.ZeroPK, nil, fmt.Errorf("missing pubkey target")
}
if strings.HasPrefix(target, "nostr:") {
target = strings.TrimPrefix(target, "nostr:")
}
if prefix, value, err := nip19.Decode(target); err == nil {
switch prefix {
case "npub":
return value.(nostr.PubKey), nil, nil
case "nprofile":
profile := value.(nostr.ProfilePointer)
return profile.PublicKey, profile.Relays, nil
}
}
if strings.Contains(target, "@") {
pp, err := nip05.QueryIdentifier(ctx, target)
if err != nil {
return nostr.ZeroPK, nil, err
}
return pp.PublicKey, pp.Relays, nil
}
pk, err := nostr.PubKeyFromHex(target)
if err != nil {
return nostr.ZeroPK, nil, err
}
return pk, nil, nil
}
func fetchSpecificPodcastEpisode(ctx context.Context, pointer nostr.EventPointer, relayHints []string) (*nostr.Event, error) {
relays := slices.Clone(relayHints)
relays = nostr.AppendUnique(relays, pointer.Relays...)
if pointer.Author != nostr.ZeroPK {
relays = nostr.AppendUnique(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for episode")
}
res := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}}, nostr.SubscriptionOptions{Label: "nak-podcast"})
if res == nil {
return nil, fmt.Errorf("podcast episode not found")
}
if res.Event.Kind != 54 {
return nil, fmt.Errorf("event is not podcast episode: expected kind 54, got %d", res.Event.Kind)
}
return &res.Event, nil
}
func fetchLatestEpisodeForPodcastSelection(ctx context.Context, pk nostr.PubKey, relayHints []string) (*nostr.Event, error) {
choices, err := discoverPodcasts(ctx, pk, relayHints)
if err != nil {
return nil, err
}
chosen := choices[0]
if len(choices) > 1 {
labels := make([]string, len(choices))
for i, item := range choices {
labels[i] = podcastLabel(item)
}
var selected string
if err := survey.AskOne(&survey.Select{
Message: "choose podcast",
Options: labels,
PageSize: 12,
}, &selected); err != nil {
return nil, err
}
for i, label := range labels {
if label == selected {
chosen = choices[i]
break
}
}
}
episode, err := fetchLatestPodcastEpisode(ctx, chosen)
if err != nil {
return nil, err
}
return episode, nil
}
func discoverPodcasts(ctx context.Context, pk nostr.PubKey, relayHints []string) ([]podcastInfo, error) {
choices := make([]podcastInfo, 0, 4)
seen := make(map[nostr.PubKey]struct{}, 4)
if direct, ok := fetchPodcastMetadata(ctx, pk, relayHints); ok {
choices = append(choices, direct)
seen[pk] = struct{}{}
}
authoredRelays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
for _, candidatePubKey := range fetchAuthoredPodcastPubKeys(ctx, pk, authoredRelays) {
if _, ok := seen[candidatePubKey]; ok {
continue
}
meta, ok := fetchPodcastMetadata(ctx, candidatePubKey, relayHints)
if !ok {
continue
}
if !podcastClaimsAuthor(meta.Metadata, pk) {
continue
}
choices = append(choices, meta)
seen[candidatePubKey] = struct{}{}
}
if len(choices) == 0 {
return nil, fmt.Errorf("no podcasts found for %s", nip19.EncodeNpub(pk))
}
return choices, nil
}
func fetchPodcastMetadata(ctx context.Context, pk nostr.PubKey, relayHints []string) (podcastInfo, bool) {
relays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
if len(relays) == 0 {
return podcastInfo{}, false
}
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10154},
Authors: []nostr.PubKey{pk},
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
if !ok {
return podcastInfo{}, false
}
return podcastInfo{
PubKey: pk,
Title: podcastTagValue(evt, "title"),
Image: podcastTagValue(evt, "image"),
Relays: relays,
Metadata: evt,
}, true
}
func fetchAuthoredPodcastPubKeys(ctx context.Context, pk nostr.PubKey, relays []string) []nostr.PubKey {
if len(relays) == 0 {
return nil
}
found := make([]nostr.PubKey, 0, 4)
seen := make(map[nostr.PubKey]struct{}, 4)
for _, kind := range []nostr.Kind{10064, 10164} {
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{kind},
Authors: []nostr.PubKey{pk},
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
if !ok {
continue
}
for _, tag := range evt.Tags {
if len(tag) < 2 || tag[0] != "p" {
continue
}
podcastPK, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
if _, ok := seen[podcastPK]; ok {
continue
}
seen[podcastPK] = struct{}{}
found = append(found, podcastPK)
}
}
return found
}
func podcastClaimsAuthor(metadata nostr.Event, author nostr.PubKey) bool {
for _, tag := range metadata.Tags {
if len(tag) >= 2 && tag[0] == "p" && tag[1] == author.Hex() {
return true
}
}
return false
}
func fetchLatestPodcastEpisode(ctx context.Context, podcast podcastInfo) (*nostr.Event, error) {
relays := podcast.Relays
if len(relays) == 0 {
relays = sys.FetchOutboxRelays(ctx, podcast.PubKey, 3)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(podcast.PubKey))
}
var latest *nostr.Event
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{54},
Authors: []nostr.PubKey{podcast.PubKey},
Limit: 20,
}, nostr.SubscriptionOptions{Label: "nak-podcast"}) {
if latest == nil || ie.Event.CreatedAt > latest.CreatedAt {
event := ie.Event
latest = &event
}
}
if latest == nil {
return nil, fmt.Errorf("no episodes found for %s", podcastLabel(podcast))
}
return latest, nil
}
func playPodcastEpisode(player string, episode *nostr.Event) error {
audioURL := firstPodcastAudioURL(*episode)
if audioURL == "" {
return fmt.Errorf("podcast episode has no audio tag")
}
title := podcastTagValue(*episode, "title")
if title == "" {
title = episode.ID.Hex()
}
stdout(colors.bold("playing:"), color.HiBlueString(title))
stdout(colors.bold("audio:"), color.HiCyanString(audioURL))
cmd, err := buildPodcastPlayerCommand(player, audioURL)
if err != nil {
return err
}
cmd.Stdin = nil
cmd.Stdout = color.Output
cmd.Stderr = color.Error
return cmd.Run()
}
func buildPodcastPlayerCommand(player string, audioURL string) (*exec.Cmd, error) {
if strings.TrimSpace(player) != "" {
parts := strings.Fields(player)
if len(parts) == 0 {
return nil, fmt.Errorf("invalid player command %q", player)
}
return exec.Command(parts[0], append(parts[1:], audioURL)...), nil
}
for _, candidate := range podcastPlayerCandidates() {
if _, err := exec.LookPath(candidate.name); err == nil {
return exec.Command(candidate.name, append(candidate.args, audioURL)...), nil
}
}
return nil, fmt.Errorf("no media player found; install mpv, vlc, ffplay or pass --player")
}
func podcastPlayerCandidates() []struct {
name string
args []string
} {
if runtime.GOOS == "darwin" {
return []struct {
name string
args []string
}{
{"mpv", nil},
{"vlc", nil},
{"ffplay", []string{"-nodisp", "-autoexit"}},
{"open", nil},
}
}
if runtime.GOOS == "windows" {
return []struct {
name string
args []string
}{
{"mpv.exe", nil},
{"vlc.exe", nil},
{"ffplay.exe", []string{"-nodisp", "-autoexit"}},
{"cmd", []string{"/c", "start"}},
}
}
return []struct {
name string
args []string
}{
{"mpv", nil},
{"vlc", nil},
{"ffplay", []string{"-nodisp", "-autoexit"}},
{"xdg-open", nil},
}
}
func firstPodcastAudioURL(evt nostr.Event) string {
for _, tag := range evt.Tags {
if len(tag) >= 2 && tag[0] == "audio" {
return tag[1]
}
}
return ""
}
func podcastTagValue(evt nostr.Event, name string) string {
if tag := evt.Tags.Find(name); len(tag) >= 2 {
return tag[1]
}
return ""
}
func podcastLabel(info podcastInfo) string {
title := strings.TrimSpace(info.Title)
if title == "" {
title = nip19.EncodeNpub(info.PubKey)
} else {
title += " (" + nip19.EncodeNpub(info.PubKey) + ")"
}
return title
}
func resolvePodcastsForPubkey(ctx context.Context, target string, relayHints []string) ([]podcastInfo, error) {
pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target)
if err != nil {
return nil, err
}
allRelays := nostr.AppendUnique(relayHints, extraRelays...)
choices, err := discoverPodcasts(ctx, pk, allRelays)
if err != nil {
return nil, err
}
if len(choices) > 1 {
labels := make([]string, len(choices))
for i, item := range choices {
labels[i] = podcastLabel(item)
}
var selected string
if err := survey.AskOne(&survey.Select{
Message: "choose podcast",
Options: labels,
PageSize: 12,
}, &selected); err != nil {
return nil, err
}
filtered := make([]podcastInfo, 0, 1)
for i, label := range labels {
if label == selected {
filtered = append(filtered, choices[i])
break
}
}
return filtered, nil
}
return choices, nil
}
func printPodcastInfo(p podcastInfo) {
stdout(colors.bold("title:"), color.HiBlueString(p.Title))
stdout(colors.bold("pubkey:"), color.HiCyanString(nip19.EncodeNpub(p.PubKey)))
if desc := podcastTagValue(p.Metadata, "description"); desc != "" {
stdout(colors.bold("description:"), desc)
}
if image := podcastTagValue(p.Metadata, "image"); image != "" {
stdout(colors.bold("image:"), color.HiCyanString(image))
}
if website := podcastTagValue(p.Metadata, "website"); website != "" {
stdout(colors.bold("website:"), color.HiCyanString(website))
}
authors := make([]string, 0, 2)
for _, tag := range p.Metadata.Tags {
if len(tag) >= 2 && tag[0] == "p" {
pk, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
author := nip19.EncodeNpub(pk)
if len(tag) >= 3 && tag[2] != "" {
author += " " + tag[2]
}
authors = append(authors, author)
}
}
if len(authors) > 0 {
stdout(colors.bold("authors:"), strings.Join(authors, ", "))
}
stdout("")
}
func printPodcastEpisodeList(ctx context.Context, p podcastInfo, limit int) error {
episodes, err := fetchPodcastEpisodes(ctx, p, limit)
if err != nil {
return err
}
if len(episodes) == 0 {
stdout(color.YellowString("no episodes found for %s", p.Title))
return nil
}
stdout(color.CyanString("episodes for %s:", p.Title))
for _, ep := range episodes {
title := podcastTagValue(ep, "title")
if title == "" {
title = ep.ID.Hex()[:16]
}
date := ep.CreatedAt.Time().Format("2006-01-02")
desc := ep.Content
if len(desc) > 100 {
desc = desc[:100] + "..."
}
stdout(fmt.Sprintf("%s %s %s", date, title, desc))
}
return nil
}
func fetchPodcastEpisodes(ctx context.Context, p podcastInfo, limit int) ([]nostr.Event, error) {
relays := p.Relays
if len(relays) == 0 {
relays = sys.FetchOutboxRelays(ctx, p.PubKey, 3)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(p.PubKey))
}
episodes := make([]nostr.Event, 0, limit)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{54},
Authors: []nostr.PubKey{p.PubKey},
Limit: limit,
}, nostr.SubscriptionOptions{Label: "nak-podcast-list"}) {
episodes = append(episodes, ie.Event)
}
return episodes, nil
}

140
profile.go Normal file
View File

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

307
publish.go Normal file
View File

@@ -0,0 +1,307 @@
package main
import (
"context"
"fmt"
"io"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
var publish = &cli.Command{
Name: "publish",
Usage: "publishes a note with content from stdin or argument",
Description: `reads content and publishes it as a note, optionally as a reply to another note.
Either pipe content or pass it as the first argument.
example:
echo "hello world" | nak publish
nak publish "hello world"
echo "I agree!" | nak publish --reply nevent1...
nak publish -t t=mytag -t e=someeventid "tagged post"`,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also publish to these relays",
},
&cli.StringFlag{
Name: "reply",
Usage: "event id, naddr1 or nevent1 code to reply to",
},
&cli.StringFlag{
Name: "comment",
Usage: "event id, naddr1 or nevent1 code to comment on (NIP-22 kind 1111, takes precedence over --reply)",
},
&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: "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 {
var content []byte
if isPiped() {
var err error
content, err = io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
} else if firstArg := c.Args().First(); firstArg != "" {
content = []byte(firstArg)
} else {
return fmt.Errorf("no content. pipe or pass content as argument.")
}
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/comment flag
// --comment takes precedence over --reply
target := c.String("comment")
isComment := c.IsSet("comment")
if target == "" {
target = c.String("reply")
}
var replyRelays []string
if target != "" {
replyEvent, relayHints, err := sys.FetchSpecificEventFromInput(ctx, target, sdk.FetchSpecificEventParameters{})
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
if isComment || replyEvent.Kind != 1 {
// NIP-22 comment
evt.Kind = 1111
buildNIP22Tags(&evt, replyEvent, relayHints)
} else {
// NIP-10 reply
if replyEvent.Kind != 1 {
evt.Kind = 1111
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
}
// add reply tags per NIP-10
rootID := ""
rootRelay := ""
for _, tag := range replyEvent.Tags {
if len(tag) >= 2 && tag[0] == "e" {
if len(tag) >= 4 && tag[3] == "root" {
rootID = tag[1]
if len(tag) >= 3 {
rootRelay = tag[2]
}
break
}
if rootID == "" {
rootID = tag[1]
if len(tag) >= 3 {
rootRelay = tag[2]
}
}
}
}
if rootID == "" {
// replying to root event
evt.Tags = append(evt.Tags,
nostr.Tag{"e", replyEvent.ID.Hex(), "", "root"},
)
} else {
// replying to a reply
evt.Tags = append(evt.Tags,
nostr.Tag{"e", rootID, rootRelay, "root"},
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
)
}
evt.Tags = append(evt.Tags,
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.StringSlice("relay")...)
relays := connectToAllRelays(ctx, c, relayUrls)
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)
},
}
func buildNIP22Tags(evt *nostr.Event, target *nostr.Event, relayHints []string) {
var relayHint string
if len(relayHints) > 0 {
relayHint = relayHints[0]
}
dtag := func() string {
for _, tag := range target.Tags {
if len(tag) >= 2 && tag[0] == "d" {
return tag[1]
}
}
return ""
}
// find root scope in target's uppercase NIP-22 tags
var rootScopeName, rootScopeValue, rootScopeRelay, rootKind, rootPubkey string
for _, tag := range target.Tags {
if len(tag) >= 2 && (tag[0] == "E" || tag[0] == "A" || tag[0] == "I") {
rootScopeName = tag[0]
rootScopeValue = tag[1]
if len(tag) >= 3 {
rootScopeRelay = tag[2]
}
break
}
}
if rootScopeName != "" {
for _, tag := range target.Tags {
if len(tag) >= 2 && tag[0] == "K" {
rootKind = tag[1]
break
}
}
for _, tag := range target.Tags {
if len(tag) >= 2 && tag[0] == "P" {
rootPubkey = tag[1]
break
}
}
} else {
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
rootScopeName = "A"
rootScopeValue = fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
} else {
rootScopeName = "E"
rootScopeValue = target.ID.Hex()
}
rootScopeRelay = relayHint
rootKind = fmt.Sprint(target.Kind)
rootPubkey = target.PubKey.Hex()
}
// root scope tag
rootScopeTag := nostr.Tag{rootScopeName, rootScopeValue}
if rootScopeName == "E" && rootPubkey != "" {
// the pubkey goes in the 4th position, so the relay hint must be
// present (even if empty) to keep it there
rootScopeTag = append(rootScopeTag, rootScopeRelay, rootPubkey)
} else if rootScopeRelay != "" {
rootScopeTag = append(rootScopeTag, rootScopeRelay)
}
evt.Tags = append(evt.Tags, rootScopeTag)
if rootKind != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"K", rootKind})
}
if rootPubkey != "" && rootScopeName != "I" {
evt.Tags = append(evt.Tags, nostr.Tag{"P", rootPubkey})
}
// parent tags (lowercase) - always reference the target event
if target.Kind.IsAddressable() || target.Kind.IsReplaceable() {
aValue := fmt.Sprintf("%d:%s:%s", target.Kind, target.PubKey.Hex(), dtag())
aTag := nostr.Tag{"a", aValue}
if relayHint != "" {
aTag = append(aTag, relayHint)
}
evt.Tags = append(evt.Tags, aTag)
}
eTag := nostr.Tag{"e", target.ID.Hex()}
if target.PubKey != (nostr.PubKey{}) {
// same here: keep the pubkey in the 4th position
eTag = append(eTag, relayHint, target.PubKey.Hex())
} else if relayHint != "" {
eTag = append(eTag, relayHint)
}
evt.Tags = append(evt.Tags, eTag)
evt.Tags = append(evt.Tags, nostr.Tag{"k", fmt.Sprint(target.Kind)})
pTag := nostr.Tag{"p", target.PubKey.Hex()}
if relayHint != "" {
pTag = append(pTag, relayHint)
}
evt.Tags = append(evt.Tags, pTag)
}

View File

@@ -1,37 +1,37 @@
package main
import (
"encoding/json"
"context"
"fmt"
"strings"
"github.com/nbd-wtf/go-nostr/nip11"
"github.com/urfave/cli/v2"
"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`,
ArgsUsage: "<relay-url>",
Action: func(c *cli.Context) error {
url := c.Args().First()
if url == "" {
return fmt.Errorf("specify the <relay-url>")
}
Description: `
nak relay nostr.wine
`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for url := range getStdinLinesOrArguments(c.Args()) {
if url == "" {
return fmt.Errorf("specify the <relay-url>")
}
if !strings.HasPrefix(url, "wss://") && !strings.HasPrefix(url, "ws://") {
url = "wss://" + url
}
info, err := nip11.Fetch(ctx, url)
if err != nil {
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %s", url, err)
continue
}
info, err := nip11.Fetch(c.Context, url)
if err != nil {
return fmt.Errorf("failed to fetch '%s' information document: %w", url, err)
pretty, _ := json.MarshalIndent(info, "", " ")
stdout(string(pretty))
}
pretty, _ := json.MarshalIndent(info, "", " ")
stdout(string(pretty))
exitIfLineProcessingError(ctx)
return nil
},
}

763
req.go
View File

@@ -1,23 +1,36 @@
package main
import (
"encoding/json"
"bufio"
"context"
"fmt"
"math"
"os"
"strconv"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/eventstore/wrappers"
"fiatjaf.com/nostr/nip77"
"fiatjaf.com/nostr/schema"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"golang.org/x/sync/errgroup"
)
const CATEGORY_FILTER_ATTRIBUTES = "FILTER ATTRIBUTES"
const (
CATEGORY_FILTER_ATTRIBUTES = "FILTER ATTRIBUTES"
// CATEGORY_SIGNER = "SIGNER OPTIONS" -- defined at event.go as the same (yes, I know)
)
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
@@ -27,134 +40,110 @@ 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`,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
DisableSliceFlagSeparator: true,
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
},
&cli.StringSliceFlag{
Name: "id",
Aliases: []string{"i"},
Usage: "only accept events with these ids (hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
&cli.BoolFlag{
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "d",
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
&cli.BoolFlag{
Name: "no-verify",
Usage: "skip event signature verification from relays",
},
&cli.StringFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.StringFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringFlag{
Name: "search",
Usage: "a NIP-50 search query, use it only with relays that explicitly support it",
Category: CATEGORY_FILTER_ATTRIBUTES,
&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: "read from \"write\" relays of \"authors\" and/or from the \"read\" relays of any \"#p\" or \"#P\" tags",
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
DefaultText: "false",
},
&cli.DurationFlag{
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&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",
Name: "spell",
Usage: "output a spell event (kind 777) instead of a filter",
},
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the AUTH challenge, as hex or nsec",
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 AUTH challenge",
},
&cli.StringFlag{
Name: "connect",
Usage: "sign AUTH 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",
},
},
),
ArgsUsage: "[relay...]",
Action: func(c *cli.Context) error {
var pool *nostr.SimplePool
Action: func(ctx context.Context, c *cli.Command) error {
if c.Bool("no-verify") {
sys.Pool.RelayOptions.AssumeValid = true
}
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
if negentropy {
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
return fmt.Errorf("negentropy is incompatible with --stream, --outbox or --paginate")
}
}
if c.Bool("paginate") && c.Bool("stream") {
return fmt.Errorf("incompatible flags --paginate and --stream")
}
if c.Bool("paginate") && c.Bool("outbox") {
return fmt.Errorf("incompatible flags --paginate and --outbox")
}
if c.Bool("bare") && c.Bool("spell") {
return fmt.Errorf("incompatible flags --bare and --spell")
}
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
var relays []*nostr.Relay
pool, relays = connectToAllRelays(c.Context, relayUrls, nostr.WithAuthHandler(func(evt *nostr.Event) error {
if !c.Bool("auth") {
return fmt.Errorf("auth not authorized")
}
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(c)
if err != nil {
return err
}
var pk string
if bunker != nil {
pk, err = bunker.GetPublicKey(c.Context)
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...\n", pk)
if len(relayUrls) > 0 && (c.Bool("bare") || c.Bool("spell")) {
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
}
if bunker != nil {
return bunker.SignEvent(c.Context, evt)
} else {
return evt.Sign(sec)
}
}))
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
if jq != nil && len(relayUrls) == 0 && !c.Bool("outbox") {
return fmt.Errorf("--jq requires relay URLs or --outbox")
}
if len(relayUrls) > 0 && !negentropy {
relays := connectToAllRelays(
ctx,
c,
relayUrls,
)
// 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)
@@ -163,114 +152,490 @@ 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 {
lineProcessingError(c, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
continue
}
}
if authors := c.StringSlice("author"); len(authors) > 0 {
filter.Authors = append(filter.Authors, authors...)
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
if ids := c.StringSlice("id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
if kinds := c.IntSlice("kind"); len(kinds) > 0 {
filter.Kinds = append(filter.Kinds, kinds...)
}
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)
if len(relayUrls) > 0 || c.Bool("outbox") {
if negentropy {
store := &slicestore.SliceStore{}
store.Init()
if syncFile := c.String("only-missing"); syncFile != "" {
file, err := os.Open(syncFile)
if err != nil {
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 {
if err == eventstore.ErrDupEvent {
continue
}
return fmt.Errorf("failed to save event from sync file: %w", err)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read sync file: %w", err)
}
}
target := PrintingQuerierPublisher{
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
jq: jq,
}
var source nostr.Querier = nil
if c.IsSet("only-missing") {
source = target
}
handle := nip77.SyncEventsFromIDs
if c.Bool("ids-only") {
seen := make(map[nostr.ID]struct{}, max(500, filter.Limit))
handle = func(ctx context.Context, dir nip77.Direction) {
for id := range dir.Items {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
stdout(id.Hex())
}
}
}
for _, url := range relayUrls {
err := nip77.NegentropySync(ctx, url, filter, source, target, handle)
if err != nil {
log("negentropy sync from %s failed: %s", url, err)
}
}
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", dtag})
}
if len(tags) > 0 && filter.Tags == nil {
filter.Tags = make(nostr.TagMap)
}
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
}
if since := c.String("since"); since != "" {
if since == "now" {
ts := nostr.Now()
filter.Since = &ts
} else if i, err := strconv.Atoi(since); err == nil {
ts := nostr.Timestamp(i)
filter.Since = &ts
} else {
return fmt.Errorf("parse error: Invalid numeric literal %q", since)
}
}
if until := c.String("until"); until != "" {
if until == "now" {
ts := nostr.Now()
filter.Until = &ts
} else if i, err := strconv.Atoi(until); err == nil {
ts := nostr.Timestamp(i)
filter.Until = &ts
} else {
return fmt.Errorf("parse error: Invalid numeric literal %q", until)
}
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = limit
}
if len(relayUrls) > 0 {
fn := pool.SubManyEose
if c.Bool("stream") {
fn = pool.SubMany
}
for ie := range fn(c.Context, relayUrls, nostr.Filters{filter}) {
stdout(ie.Event)
if err := performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req", jq); err != nil {
return err
}
}
} else {
// no relays given, will just print the filter
// no relays given, will just print the filter or spell
var result string
if c.Bool("bare") {
if c.Bool("spell") {
// output a spell event instead of a filter
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
spellEvent := createSpellEvent(ctx, filter, kr)
j, _ := json.Marshal(spellEvent)
result = string(j)
} else if c.Bool("bare") {
// bare filter output
result = filter.String()
} else {
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: nostr.Filters{filter}})
// normal filter
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
result = string(j)
}
}
stdout(result)
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
}
func performReq(
ctx context.Context,
filter nostr.Filter,
relayUrls []string,
stream bool,
outbox bool,
outboxRelaysPerPubKey uint64,
paginate bool,
paginateInterval time.Duration,
label string,
jq jqProcessor,
) error {
var results chan nostr.RelayEvent
var closeds chan nostr.RelayClosed
opts := nostr.SubscriptionOptions{
Label: label,
}
if paginate {
paginator := sys.Pool.PaginatorWithInterval(paginateInterval)
results = paginator(ctx, relayUrls, filter, opts)
} else if outbox {
defs := make([]nostr.DirectedFilter, 0, len(filter.Authors)*2)
pTagPubkeys := make([]nostr.PubKey, 0, 4)
if len(filter.Authors) == 0 && filter.Tags != nil {
seen := make(map[nostr.PubKey]struct{})
pTags := append([]string{}, filter.Tags["p"]...)
pTags = append(pTags, filter.Tags["P"]...)
for _, value := range pTags {
pubkey, err := parsePubKey(value)
if err != nil {
continue
}
if _, ok := seen[pubkey]; ok {
continue
}
seen[pubkey] = struct{}{}
pTagPubkeys = append(pTagPubkeys, pubkey)
}
}
for _, relayUrl := range relayUrls {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: relayUrl,
})
}
if len(filter.Authors) == 0 && len(pTagPubkeys) > 0 {
// relays for p tags when no authors are given
errg := errgroup.Group{}
errg.SetLimit(16)
mu := sync.Mutex{}
logverbose("gathering inbox relays for %d p-tags...\n", len(pTagPubkeys))
for _, pubkey := range pTagPubkeys {
errg.Go(func() error {
n := int(outboxRelaysPerPubKey)
for _, url := range sys.FetchInboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already specified globally, ignore
continue
}
if !nostr.IsValidRelayURL(url) {
continue
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
mu.Lock()
if slices.IndexFunc(defs, matchUrl) == -1 {
// new relay, add it
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
}
mu.Unlock()
}
return nil
})
}
errg.Wait()
} else {
// relays for each pubkey
errg := errgroup.Group{}
errg.SetLimit(16)
mu := sync.Mutex{}
logverbose("gathering outbox relays for %d authors...\n", len(filter.Authors))
for _, pubkey := range filter.Authors {
errg.Go(func() error {
n := int(outboxRelaysPerPubKey)
for _, url := range sys.FetchOutboxRelays(ctx, pubkey, n) {
if slices.Contains(relayUrls, url) {
// already specified globally, ignore
continue
}
if !nostr.IsValidRelayURL(url) {
continue
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
mu.Lock()
if idx := slices.IndexFunc(defs, matchUrl); idx == -1 {
// new relay, add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
} else {
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
mu.Unlock()
}
return nil
})
}
errg.Wait()
}
if stream {
logverbose("running subscription with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedSubscribeManyNotifyClosed(ctx, defs, opts)
} else {
logverbose("running query with %d directed filters...\n", len(defs))
results, closeds = sys.Pool.BatchedQueryManyNotifyClosed(ctx, defs, opts)
}
} else {
if stream {
logverbose("running subscription to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.SubscribeManyNotifyClosed(ctx, relayUrls, filter, opts)
} else {
logverbose("running query to %d relays...\n", len(relayUrls))
results, closeds = sys.Pool.FetchManyNotifyClosed(ctx, relayUrls, filter, opts)
}
}
readevents:
for {
select {
case ie, stillOpen := <-results:
if !stillOpen {
break readevents
}
var out string
if jq == nil {
out = ie.Event.String()
} else {
v, matches, err := jq(ie.Event)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if !matches {
continue
}
out = v
}
stdout(out)
case closed, stillOpen := <-closeds:
if stillOpen {
if closed.HandledAuth {
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
} else {
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
}
}
case <-ctx.Done():
break readevents
}
}
return nil
}
var reqFilterFlags = []cli.Flag{
&PubKeyOrAddressFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&IDSliceFlag{
Name: "id",
Aliases: []string{"i"},
Usage: "only accept events with these ids",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&KindSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers or kind names",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "d",
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "h",
Usage: "shortcut for --tag h=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.UintFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringFlag{
Name: "search",
Usage: "a nip50 search query, use it only with relays that explicitly support it",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
}
type flagTag struct {
key string
value string
}
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
tags := make([]flagTag, 0, 5)
if as := getPubKeyOrAddressSlice(c, "author"); len(as) > 0 {
for _, author := range as {
// is it an address?
if author.Addr != nil {
tags = append(tags, flagTag{"a", author.Addr.AsTagReference()})
continue
}
// or is it an "author" pubkey?
if author.PubKey != nostr.ZeroPK {
filter.Authors = append(filter.Authors, author.PubKey)
}
}
}
if ids := getIDSlice(c, "id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
if kinds := getKindSlice(c, "kind"); len(kinds) > 0 {
filter.Kinds = append(filter.Kinds, kinds...)
}
if search := c.String("search"); search != "" {
filter.Search = search
}
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
val := spl[1]
if len(spl[0]) == 1 {
val = decodeTagValue(val, rune(spl[0][0]))
}
tags = append(tags, flagTag{spl[0], val})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, flagTag{"e", decodeTagValue(etag, 'e')})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, flagTag{"p", decodeTagValue(ptag, 'p')})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, flagTag{"d", dtag})
}
for _, htag := range c.StringSlice("h") {
tags = append(tags, flagTag{"h", htag})
}
if len(tags) > 0 && filter.Tags == nil {
filter.Tags = make(nostr.TagMap)
}
for _, tag := range tags {
if _, ok := filter.Tags[tag.key]; !ok {
filter.Tags[tag.key] = make([]string, 0, 3)
}
filter.Tags[tag.key] = append(filter.Tags[tag.key], tag.value)
}
if c.IsSet("since") {
filter.Since = getNaturalDate(c, "since")
}
if c.IsSet("until") {
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Uint("limit"); limit != 0 {
filter.Limit = int(limit)
} else if c.IsSet("limit") {
filter.LimitZero = true
}
return nil
}
type PrintingQuerierPublisher struct {
nostr.QuerierPublisher
jq jqProcessor
}
func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event) error {
if err := p.QuerierPublisher.Publish(ctx, evt); err == nil {
var out string
if p.jq == nil {
out = evt.String()
} else {
v, matches, err := p.jq(evt)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if !matches {
return nil
}
out = v
}
stdout(out)
return nil
} else if err == eventstore.ErrDupEvent {
return nil
} else {
return err
}
}

300
serve.go Normal file
View File

@@ -0,0 +1,300 @@
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/khatru"
"fiatjaf.com/nostr/khatru/blossom"
"fiatjaf.com/nostr/khatru/grasp"
"github.com/bep/debounce"
"github.com/fatih/color"
"github.com/puzpuzpuz/xsync/v3"
"github.com/urfave/cli/v3"
)
var serve = &cli.Command{
Name: "serve",
Usage: "starts an in-memory relay for testing purposes",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "hostname",
Usage: "hostname where to listen for connections",
Value: "localhost",
},
&cli.UintFlag{
Name: "port",
Usage: "port where to listen for connections",
Value: 10547,
},
&cli.StringFlag{
Name: "events",
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",
},
&cli.BoolFlag{
Name: "auth",
Usage: "require AUTH for all operations",
},
&cli.BoolFlag{
Name: "eager-auth",
Usage: "send AUTH challenge immediately on connect",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
db := &slicestore.SliceStore{}
var blobStore *xsync.MapOf[string, []byte]
var repoDir string
var scanner *bufio.Scanner
if path := c.String("events"); path != "" {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to file at '%s': %w", path, err)
}
scanner = bufio.NewScanner(f)
} else if isPiped() {
scanner = bufio.NewScanner(os.Stdin)
}
if scanner != nil {
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
i := 0
for scanner.Scan() {
var evt nostr.Event
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(evt)
i++
}
}
rl := khatru.NewRelay()
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
}
exited := make(chan error)
hostname := c.String("hostname")
port := int(c.Uint("port"))
totalConnections := atomic.Int32{}
rl.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
if c.Bool("eager-auth") {
khatru.RequestAuth(ctx)
}
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
rl.OnAuth = func(ctx context.Context, pubkey nostr.PubKey) {
log(" got %s %s\n", color.GreenString("authenticated"), pubkey.Hex())
}
d := debounce.New(time.Second * 2)
var printStatus func()
printStatus = func() {
d(func() {
totalEvents, err := db.CountEvents(nostr.Filter{})
if err != nil {
log("failed to count: %s\n", err)
}
subs := rl.GetListeningFilters()
blossomMsg := ""
if c.Bool("blossom") {
blobsStored := blobStore.Size()
blossomMsg = fmt.Sprintf("blobs: %s, ",
color.HiMagentaString("%d", blobsStored),
)
}
graspMsg := ""
if c.Bool("grasp") {
gitAnnounced := 0
gitStored := 0
for evt := range db.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{nostr.Kind(30617)}}, 500) {
gitAnnounced++
identifier := evt.Tags.GetD()
if info, err := os.Stat(filepath.Join(repoDir, identifier)); err == nil && info.IsDir() {
gitStored++
}
}
graspMsg = fmt.Sprintf("git announced: %s, git stored: %s, ",
color.HiMagentaString("%d", gitAnnounced),
color.HiMagentaString("%d", gitStored),
)
}
log(" %s events: %s, %s%sconnections: %s, subscriptions: %s\n",
color.HiMagentaString("•"),
color.HiMagentaString("%d", totalEvents),
blossomMsg,
graspMsg,
color.HiMagentaString("%d", totalConnections.Load()),
color.HiMagentaString("%d", len(subs)),
)
})
}
if c.Bool("blossom") {
bs := blossom.New(rl, fmt.Sprintf("http://%s:%d", hostname, port))
bs.Store = blossom.NewMemoryBlobIndex()
blobStore = xsync.NewMapOf[string, []byte]()
bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
blobStore.Store(sha256+ext, body)
log(" got %s %s\n", color.GreenString("blob stored"), sha256+ext)
printStatus()
return nil
}
bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) {
if body, ok := blobStore.Load(sha256 + ext); ok {
log(" got %s %s\n", color.BlueString("blob downloaded"), sha256+ext)
printStatus()
return bytes.NewReader(body), nil, nil
}
return nil, nil, nil
}
bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error {
blobStore.Delete(sha256 + ext)
log(" got %s %s\n", color.RedString("blob deleted"), sha256+ext)
printStatus()
return nil
}
}
if c.Bool("grasp") {
repoDir = c.String("grasp-path")
if repoDir == "" {
var err error
repoDir, err = os.MkdirTemp("", "nak-serve-grasp-repos-")
if err != nil {
return fmt.Errorf("failed to create grasp repos directory: %w", err)
}
}
g := grasp.New(rl, repoDir)
g.OnRead = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
log(" got %s %s %s\n", color.CyanString("git read"), pubkey.Hex(), repo)
printStatus()
return false, ""
}
g.OnWrite = func(ctx context.Context, pubkey nostr.PubKey, repo string) (reject bool, reason string) {
log(" got %s %s %s\n", color.YellowString("git write"), pubkey.Hex(), repo)
printStatus()
return false, ""
}
}
ln, err := net.Listen("tcp", net.JoinHostPort(hostname, strconv.Itoa(port)))
if err != nil {
return err
}
go func() {
exited <- http.Serve(ln, rl)
}()
// relay logging
rl.OnRequest = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if c.Bool("auth") {
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
return true, "auth-required: subscribe"
}
}
negentropy := ""
if khatru.IsNegentropySession(ctx) {
negentropy = color.HiBlueString("negentropy ")
}
authedString := ""
if pubkey, ok := khatru.GetAuthed(ctx); ok {
authedString = fmt.Sprintf(" from %s", color.GreenString(pubkey.Hex()))
}
log(" got %s%s %v%s\n",
negentropy, color.HiYellowString("request"), colors.italic(filter), authedString)
printStatus()
return false, ""
}
rl.OnCount = func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if c.Bool("auth") {
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
return true, "auth-required: count"
}
}
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
printStatus()
return false, ""
}
rl.OnEvent = func(ctx context.Context, event nostr.Event) (reject bool, msg string) {
if c.Bool("auth") {
if _, isAuthed := khatru.GetAuthed(ctx); !isAuthed {
return true, "auth-required: event"
}
}
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
printStatus()
return false, ""
}
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
},
}

536
spell.go Normal file
View File

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

490
sync.go Normal file
View File

@@ -0,0 +1,490 @@
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
mu sync.Mutex
err error
}
// fail records the error and closes the messages channel so a Receive()
// that is already blocked on it wakes up instead of hanging forever.
func (rtpr *RelayThirdPartyRemote) fail(err error) {
rtpr.mu.Lock()
if rtpr.err == nil {
rtpr.err = err
close(rtpr.messages)
}
rtpr.mu.Unlock()
}
func (rtpr *RelayThirdPartyRemote) getErr() error {
rtpr.mu.Lock()
defer rtpr.mu.Unlock()
return rtpr.err
}
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.fail(fmt.Errorf("unexpected %s received from relay", env.Label()))
return
case *nip77.ErrorEnvelope:
rtpr.fail(fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason))
return
case *nip77.MessageEnvelope:
rtpr.mu.Lock()
failed := rtpr.err != nil
rtpr.mu.Unlock()
if !failed {
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 msg, ok := <-rtpr.messages; ok {
return msg, nil
}
// channel closed by fail()
if err := rtpr.getErr(); err != nil {
return "", err
}
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 {
// always close Deltas so the consumer goroutine doesn't hang when we return an error
defer close(n.Deltas)
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()
return nil
}
func createInitialMessage() string {
output := bytes.NewBuffer(make([]byte, 0, 64))
output.WriteByte(negentropy.ProtocolVersion)
dummy := negentropy.BoundWriter{}
dummy.WriteBound(output, negentropy.InfiniteBound)
output.WriteByte(byte(negentropy.FingerprintMode))
// hardcoded random fingerprint
fingerprint := [negentropy.FingerprintSize]byte{
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
}
output.Write(fingerprint[:])
return nostr.HexEncodeToString(output.Bytes())
}
func parseMessageBuildNext(
msg string,
skippedBounds map[boundKey]struct{},
idCallback func(id nostr.ID),
skipCallback func(boundKey boundKey),
) (string, error) {
msgb, err := nostr.HexDecodeString(msg)
if err != nil {
return "", err
}
br := &negentropy.BoundReader{}
bw := &negentropy.BoundWriter{}
nextMsg := bytes.NewBuffer(make([]byte, 0, len(msgb)))
acc := &storage.Accumulator{} // this will be used for building our own fingerprints and also as a placeholder
reader := bytes.NewReader(msgb)
pv, err := reader.ReadByte()
if err != nil {
return "", err
}
if pv != negentropy.ProtocolVersion {
return "", fmt.Errorf("unsupported protocol version %v", pv)
}
nextMsg.WriteByte(pv)
for reader.Len() > 0 {
bound, err := br.ReadBound(reader)
if err != nil {
return "", err
}
modeVal, err := negentropy.ReadVarInt(reader)
if err != nil {
return "", err
}
mode := negentropy.Mode(modeVal)
switch mode {
case negentropy.SkipMode:
skipCallback(getBoundKey(bound))
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, uint64(negentropy.SkipMode))
}
case negentropy.FingerprintMode:
_, err = reader.Read(acc.Buf[0:negentropy.FingerprintSize] /* use this buffer as a dummy */)
if err != nil {
return "", err
}
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
nextMsg.Write(acc.Buf[0:negentropy.FingerprintSize] /* idem */)
}
case negentropy.IdListMode:
// when receiving an idlist we will never send this bound again to this peer
skipCallback(getBoundKey(bound))
// and instead of sending these ids to the other peer we'll send a fingerprint
acc.Reset()
numIds, err := negentropy.ReadVarInt(reader)
if err != nil {
return "", err
}
for range numIds {
id := nostr.ID{}
_, err = reader.Read(id[:])
if err != nil {
return "", err
}
idCallback(id)
acc.AddBytes(id[:])
}
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
fingerprint := acc.GetFingerprint(numIds)
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
nextMsg.Write(fingerprint[:])
}
default:
return "", fmt.Errorf("unknown mode %v", mode)
}
}
return nostr.HexEncodeToString(nextMsg.Bytes()), nil
}

321
thread_helpers.go Normal file
View File

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

72
validate.go Normal file
View File

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

View File

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

567
wallet.go Normal file
View File

@@ -0,0 +1,567 @@
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 wallet")
}
w.Processed = func(evt nostr.Event, err error) {
if err == nil {
logverbose("processed event %s\n", evt)
if c.Bool("stream") {
// after EOSE log updates and the new balance
select {
case <-w.Stable:
switch evt.Kind {
case 5:
log("- token deleted\n")
case 7375:
log("- token added\n")
default:
return
}
log(" balance: %d\n", w.Balance())
default:
}
}
} else {
log("error processing event %s: %s\n", evt, err)
}
}
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: combineFlags([][]cli.Flag{},
&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 {
if _, host, ok := strings.Cut(url, "://"); ok {
stdout(host)
} else {
stdout(url)
}
}
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 {
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
stdout(token.ID(), token.Proofs.Amount(), mintHost)
}
closew()
return nil
},
Commands: []*cli.Command{
{
Name: "drop",
Usage: "deletes a token from the wallet",
DisableSliceFlagSeparator: true,
ArgsUsage: "<id>...",
Action: func(ctx context.Context, c *cli.Command) error {
ids := c.Args().Slice()
if len(ids) == 0 {
return fmt.Errorf("no token ids specified")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
for _, token := range w.Tokens {
if slices.Contains(ids, token.ID()) {
w.DropToken(ctx, token.ID())
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), mintHost)
}
}
closew()
return nil
},
},
},
},
{
Name: "receive",
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: "target",
Usage: "npub, nprofile, nevent or hex pubkey",
},
&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 {
if c.Args().Len() < 1 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> --target <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 != "" {
if !c.Bool("force") {
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
}
if err := w.SetPrivateKey(ctx, sk); err != nil {
return err
}
}
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("mint"); len(mints) > 0 {
info.Mints = mints
} else if len(info.Mints) == 0 {
info.Mints = w.Mints
}
if len(info.Mints) == 0 {
return fmt.Errorf("missing --mint")
}
// the pubkey tag is mandatory, without it no one can send us nutzaps
info.PublicKey = nostr.PubKey(w.PublicKey.SerializeCompressed()[1:])
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