Compare commits

...

249 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
58 changed files with 9523 additions and 1492 deletions

View File

@@ -4,123 +4,49 @@ on:
push:
tags:
- '*'
pull_request:
branches: [ main, master ]
permissions:
contents: write
jobs:
make-release:
runs-on: ubuntu-latest
steps:
- uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
build-all-for-all:
runs-on: ubuntu-latest
needs:
- make-release
strategy:
fail-fast: false
matrix:
goos: [linux, freebsd, windows]
goos: [linux, freebsd, darwin, windows]
goarch: [amd64, arm64, riscv64]
exclude:
- goarch: arm64
goos: windows
- goarch: riscv64
goos: windows
- goarch: arm64
goos: freebsd
- goarch: riscv64
goos: freebsd
- goarch: arm64
goos: windows
- goarch: riscv64
goos: windows
- goarch: riscv64
goos: darwin
- goarch: arm64
goos: freebsd
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
- uses: wangyoucao577/go-release-action@v1.40
with:
go-version: 'stable'
- name: Install FUSE dependencies
run: |
sudo apt-get update
sudo apt-get install -y libfuse-dev
- name: Install cross-compilation tools for ARM64
if: matrix.goarch == 'arm64' && matrix.goos == 'linux'
run: |
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Install cross-compilation tools for RISC-V
if: matrix.goarch == 'riscv64' && matrix.goos == 'linux'
run: |
sudo apt-get install -y gcc-riscv64-linux-gnu
- name: Install FreeBSD SDK
if: matrix.goos == 'freebsd'
run: |
sudo apt-get update
sudo apt-get install -y clang lld binutils-riscv64-unknown-elf 2>/dev/null || true
mkdir -p /tmp/freebsd-sdk
cd /tmp/freebsd-sdk
# Determine download path based on architecture
case "${{ matrix.goarch }}" in
arm64) DLPATH="arm64" ;;
riscv64) DLPATH="riscv" ;;
*) DLPATH="amd64" ;;
esac
wget -q "https://download.freebsd.org/releases/${DLPATH}/14.3-RELEASE/base.txz" && tar -xf base.txz
# Download and extract libfuse source
mkdir -p libfuse && cd libfuse
wget -q https://github.com/libfuse/libfuse/releases/download/fuse-2.9.9/fuse-2.9.9.tar.gz
tar -xzf fuse-2.9.9.tar.gz --strip-components=1
mkdir -p /tmp/freebsd-sdk/usr/include
cp include/fuse.h /tmp/freebsd-sdk/usr/include/
cp include/fuse_*.h /tmp/freebsd-sdk/usr/include/ 2>/dev/null || true
cd /tmp/freebsd-sdk
- name: Set cross-compiler
id: set-cc
run: |
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
elif [ "${{ matrix.goarch }}" = "riscv64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
echo "CC=riscv64-linux-gnu-gcc" >> $GITHUB_ENV
elif [ "${{ matrix.goos }}" = "freebsd" ]; then
TRIPLE="x86_64-unknown-freebsd14.3"
[ "${{ matrix.goarch }}" = "arm64" ] && TRIPLE="aarch64-unknown-freebsd14.3"
[ "${{ matrix.goarch }}" = "riscv64" ] && TRIPLE="riscv64-unknown-freebsd14.3"
echo "CC=clang --target=$TRIPLE --sysroot=/tmp/freebsd-sdk" >> $GITHUB_ENV
echo "CGO_CFLAGS=-isystem /tmp/freebsd-sdk/usr/include --target=$TRIPLE" >> $GITHUB_ENV
echo "CGO_LDFLAGS=-L/tmp/freebsd-sdk/usr/lib -L/tmp/freebsd-sdk/lib -L/tmp/freebsd-sdk/usr/lib64 --target=$TRIPLE" >> $GITHUB_ENV
fi
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: ${{ matrix.goos == 'windows' && '0' || '1' }}
run: |
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
- name: Upload Release Asset
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: ./nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
build-darwin:
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 'stable'
- name: Install macFUSE
run: brew install --cask macfuse
- name: Build binary
env:
GOOS: darwin
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '1'
run: |
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
- name: Upload Release Asset
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: ./nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
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

View File

@@ -1,8 +1,8 @@
# build stage
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
# install git and ca-certificates (needed for fetching dependencies)
RUN apk add --no-cache git ca-certificates
RUN apk add --no-cache git ca-certificates gcc musl-dev
# set working directory
WORKDIR /app
@@ -19,7 +19,7 @@ COPY . .
# build the application
# use cgo_enabled=0 to create a static binary
# use -ldflags to strip debug info and reduce binary size
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o nak .
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-w -s" -o nak .
# runtime stage
FROM alpine:latest

133
README.md
View File

@@ -1,9 +1,19 @@
# nak, the nostr army knife
install with `go install github.com/fiatjaf/nak@latest` or
[download a binary](https://github.com/fiatjaf/nak/releases).
install with this one-liner:
or get the source with `git clone https://github.com/fiatjaf/nak` then install with `go install` or run with docker using `docker build -t nak . && docker run nak event`.
```sh
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
```
- or install with `go install github.com/fiatjaf/nak@latest` if you have **Go** set up.
- or [download a binary](https://github.com/fiatjaf/nak/releases) manually.
- or get the source with `git clone https://github.com/fiatjaf/nak` then
- install with `go install`;
- or run with docker using `docker build -t nak . && docker run nak event`.
- or install with `brew install nak` if you use **macOS Homebrew**.
- or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**.
- or install with `nix-env --install nak` if you use **Nix**.
## what can you do with it?
@@ -34,7 +44,7 @@ publishing to wss://relay.damus.io... success.
"Activando modo zen…\n\n#GM #Nostr #Hispano"
```
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
### decode a NIP-19 note1 code, add a relay hint, encode it back to nevent1
```shell
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r nostr.zbd.gg
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
@@ -97,7 +107,7 @@ ok.
### sign an event collaboratively with multiple parties using musig2
```shell
~> nak event --sec 1234 -k 1 -c 'hello from a combined key' --musig 2
~> nak event --sec 1234 -k 'text note' -c 'hello from a combined key' --musig 2
the following code should be saved secretly until the next step an included with --musig-nonce-secret:
QebOT03ERmV7km22CqEqBPFmzAkgxQzGGbR7Si8yIZCBrd1N9A3LKwGLO71kbgXZ9EYFKpjiwun4u0mj5Tq6vwM3pK7x+EI8oHbkt9majKv/QN24Ix8qnwEIHxXX+mXBug==
@@ -135,6 +145,33 @@ type the password to decrypt your secret key: **********
985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7
```
### display profile information for any npub, nip05 or hex key
```shell
~> nak profile f7z.io
pubkey (hex): fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52
npub: npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
profile uri: nostr:nprofile1qqs04xzt6ldm9qhs0ctw0t58kf4z57umjzmjg6jywu0seadwtqqc75spr9mhxue69uhhq7tjv9kkjepwve5kzar2v9nzucm0d5q3qamnwvaz7te3xsczue3h0ghxjmcppemhxue69uhhytnxxaazu6t0axr23s
name: PABLOF7z
display_name: PABLOF7z
about: Magical Other Stuff Maximalist.
picture: https://m.primal.net/KwlG.jpg
banner: https://24242.io/1dc3a4a8bb2626551d4cc91e93f5b2e6ef87b1931b9db9bf4ac72dfe61c7a45b.png
website: https://pablof7z.com
nip05: _@f7z.io (verified)
lud16: pablof7z@primal.net
relays:
wss://relay.damus.io (read)
wss://relay.primal.net (read)
wss://pyramid.fiatjaf.com (write)
wss://140.f7z.io (write)
wss://r.f7z.io (read/write)
follows: 1051
~>
~> nak profile f7z.io --name
PABLOF7z
```
### sign an event using a bunker provider (amber, promenade etc)
```shell
~> export NOSTR_CLIENT_KEY="$(nak key generate)"
@@ -200,6 +237,18 @@ or give it a named profile:
~> nak bunker --profile myself ...
```
### send a `nostrconnect://` client URI to a running bunker
```shell
~> nak bunker connect 'nostrconnect://...'
```
or, if you're using a persisted profile
```shell
~> nak bunker connect --profile default 'nostrconnect://...'
```
### generate a NIP-70 protected event with a date set to two weeks ago and some multi-value tags
```shell
~> nak event --ts 'two weeks ago' -t '-' -t 'e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.whatever.com;root;a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208' -t 'p=a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208;wss://p-relay.com' -c 'I know the future'
@@ -227,7 +276,7 @@ or give it a named profile:
• events stored: 4, subscriptions opened: 1
```
### enable negentropy (nip77) support in your development relay
### enable negentropy (NIP-77) support in your development relay
```shell
~> nak serve --negentropy
```
@@ -262,6 +311,28 @@ type the password to decrypt your secret key: ********
~> nak req -i 412f2d3e73acc312942c055ac2a695dc60bf58ff97e06689a8a79e97796c4cdb relay.westernbtc.com | jq -r .content > ~/.jq
```
### filter and transform events with the built-in `--jq` helpers
extra-filter events with `select()` based on tags:
```shell
~> nak req -k 1 -l 10 relay.damus.io --jq 'select(has("t"))'
```
extract a tag value:
```shell
~> nak req -k 1 -l 10 -t t=gm relay.damus.io --jq 'value("t")'
```
make an event easier to read by removing fields and adding human-readable time:
```shell
~> nak req -k 1 -l 5 relay.damus.io --jq '. + { d: datetime } | del(.sig, .created_at)'
```
you can do these same things by piping to standalone `jq`, take the functions in https://github.com/fiatjaf/nak/blob/master/jq.go#L10 and add them to your `~/.jq` file.
### watch a NIP-53 livestream (zap.stream, amethyst, shosho etc)
```shell
~> # this requires the jq utils from the step above
@@ -321,7 +392,7 @@ echo "#surely you're joking, mr npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn6
### record and publish an audio note (yakbak, nostur etc) signed from a bunker
```shell
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 1222 --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 'voice message' --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
```
### gift-wrap an event to a recipient and publish it somewhere
@@ -331,7 +402,7 @@ ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blos
### download a gift-wrap event and unwrap it
```shell
~> nak req -p <my-public-key> -k 1059 relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
~> nak req -p <my-public-key> -k 'giftwrap' relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
```
### sync events between two relays using negentropy
@@ -427,6 +498,7 @@ gitnostr.com... ok.
```shell
~> nak git clone
~> nak git init
~> nak git status
~> nak git sync
~> nak git fetch
~> nak git pull
@@ -441,3 +513,48 @@ gitnostr.com... ok.
1a851afaa70a26faa82c5b4422ce967c07e278efc56a1413b9719b662f86551a
8031621a54b2502f5bd4dbb87c971c0a69675d252a64d69e22224f3aee6dd2b2
```
### interact with a NIP-29 group
```shell
~> nak group info "<relay>'<id>"
~> nak group admin "<relay>'<id>"
~> nak group chat "<relay>'<id>"
~> nak group chat send "<relay>'<id>" "<message>"
```
### figure out what is a given kind
```shell
~> nak kind 10050 | jq .description
"Relay list to receive DMs"
~> nak kind 'fav relays' | jq
{
"kind": 10012,
"description": "Favorite relays list",
"in_use": true,
"content": {
"type": "free"
},
"multiple": [
"relay"
],
"tags": [
{
"name": "relay",
"next": {
"type": "relay",
"required": true
}
},
{
"name": "a",
"next": {
"type": "addr",
"required": true,
"next": {
"type": "relay"
}
}
}
]
}
```

View File

@@ -25,7 +25,6 @@ var admin = &cli.Command{
nak admin changerelayname myrelay.com --name "My Relay"`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: (func() []*cli.Command {
methods := []struct {
method string
@@ -48,6 +47,17 @@ var admin = &cli.Command{
{"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))
@@ -122,6 +132,7 @@ var admin = &cli.Command{
continue
}
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
log("failed to read response: %s\n", err)
continue
@@ -132,7 +143,7 @@ var admin = &cli.Command{
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
log("%s", bodyPrintable)
continue
}
var response nip86.Response
@@ -142,10 +153,9 @@ var admin = &cli.Command{
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
log("%s", bodyPrintable)
continue
}
resp.Body.Close()
// print the result
log("\n")
@@ -167,10 +177,16 @@ var admin = &cli.Command{
func declareFlag(argName string) cli.Flag {
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
switch argName {
case "kind":
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}
}
@@ -178,8 +194,14 @@ func declareFlag(argName string) cli.Flag {
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "kind":
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)
}

View File

@@ -3,15 +3,12 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"unsafe"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/urfave/cli/v3"
@@ -23,8 +20,8 @@ var blossomCmd = &cli.Command{
UseShortOptionHandling: true,
Usage: "an army knife for blossom things",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Flags: combineFlags([][]cli.Flag{},
&cli.StringSliceFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "the hostname of the target mediaserver",
@@ -46,7 +43,11 @@ var blossomCmd = &cli.Command{
if err != nil {
return fmt.Errorf("invalid public key '%s': %w", pubkey, err)
}
client = blossom.NewClient(c.String("server"), keyer.NewReadOnlySigner(pk))
servers := c.StringSlice("server")
if err != nil {
return err
}
client = blossom.NewClient(servers[0], keyer.NewReadOnlySigner(pk))
} else {
var err error
client, err = getBlossomClient(ctx, c)
@@ -74,11 +75,17 @@ var blossomCmd = &cli.Command{
DisableSliceFlagSeparator: true,
ArgsUsage: "[files...]",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
servers := c.StringSlice("server")
if len(servers) == 0 {
return fmt.Errorf("no server specified")
}
if isPiped() {
// get file from stdin
if c.Args().Len() > 0 {
@@ -89,34 +96,36 @@ var blossomCmd = &cli.Command{
if err != nil {
return fmt.Errorf("failed to read stdin: %w", err)
}
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
if err != nil {
return err
}
j, _ := json.Marshal(bd)
stdout(string(j))
} else {
// get filenames from arguments
hasError := false
for _, fpath := range c.Args().Slice() {
bd, err := client.UploadFilePath(ctx, fpath)
for _, server := range servers {
client := blossom.NewClient(server, keyer)
bd, err := client.UploadBlob(ctx, bytes.NewReader(data), "")
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
hasError = true
ctx = lineProcessingError(ctx, "failed to upload to '%s': %s", server, err)
continue
}
j, _ := json.Marshal(bd)
stdout(string(j))
}
} else {
// get filenames from arguments
for _, fpath := range c.Args().Slice() {
for _, server := range servers {
client := blossom.NewClient(server, keyer)
bd, err := client.UploadFilePath(ctx, fpath)
if err != nil {
ctx = lineProcessingError(ctx, "failed to upload '%s' to '%s': %s", fpath, server, err)
continue
}
if hasError {
os.Exit(3)
j, _ := json.Marshal(bd)
stdout(string(j))
}
}
}
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -142,12 +151,24 @@ var blossomCmd = &cli.Command{
outputs := c.StringSlice("output")
hasError := false
for i, hash := range c.Args().Slice() {
var hash [32]byte
for i, hhash := range c.Args().Slice() {
if len(hhash) != 64 {
log("invalid blob hash '%s'\n", hhash)
hasError = true
continue
}
if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(hhash), 64)); err != nil {
log("invalid blob hash '%s': %s\n", hhash, err)
hasError = true
continue
}
if len(outputs)-1 >= i && outputs[i] != "--" {
// save to this file
err := client.DownloadToFile(ctx, hash, outputs[i])
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
log("download failed for '%s': %s\n", hhash, err)
hasError = true
}
} else {
@@ -158,7 +179,7 @@ var blossomCmd = &cli.Command{
hasError = true
continue
}
stdout(data)
os.Stdout.Write(data)
}
}
@@ -217,6 +238,11 @@ if any of the files are not found the command will fail, otherwise it will succe
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
@@ -234,47 +260,55 @@ if any of the files are not found the command will fail, otherwise it will succe
},
},
{
Name: "mirror",
Usage: "mirrors blobs from source server to target server",
Description: `lists all blobs from the source server and mirrors them to the target server using BUD-04. requires --sec to sign the authorization event.`,
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 {
targetClient, err := getBlossomClient(ctx, c)
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
// Create client for source server
sourceServer := c.Args().First()
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
sourceClient := blossom.NewClient(sourceServer, keyer)
// Get list of blobs from source server
bds, err := sourceClient.List(ctx)
if err != nil {
return fmt.Errorf("failed to list blobs from source server: %w", err)
}
// Mirror each blob to target server
hasError := false
for _, bd := range bds {
mirrored, err := mirrorBlob(ctx, targetClient, bd.URL)
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 {
fmt.Fprintf(os.Stderr, "failed to mirror %s: %s\n", bd.SHA256, err)
hasError = true
return err
}
out, _ := json.Marshal(bd)
stdout(string(out))
return nil
}
for input := range getJsonsOrBlank() {
if input == "{}" {
continue
}
j, _ := json.Marshal(mirrored)
stdout(string(j))
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))
}
if hasError {
os.Exit(3)
}
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -286,84 +320,9 @@ func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, err
if err != nil {
return nil, err
}
return blossom.NewClient(c.String("server"), keyer), nil
}
// mirrorBlob mirrors a blob from a URL to the mediaserver using BUD-04
func mirrorBlob(ctx context.Context, client *blossom.Client, url string) (*blossom.BlobDescriptor, error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to download blob from URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download blob: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read blob content: %w", err)
}
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
signer := client.GetSigner()
pubkey, _ := signer.GetPublicKey(ctx)
evt := nostr.Event{
Kind: 24242,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"t", "upload"},
{"x", hashHex},
{"expiration", fmt.Sprintf("%d", nostr.Now()+60)},
},
Content: "blossom stuff",
PubKey: pubkey,
}
if err := signer.SignEvent(ctx, &evt); err != nil {
return nil, fmt.Errorf("failed to sign authorization event: %w", err)
}
evtj, err := json.Marshal(evt)
if err != nil {
return nil, fmt.Errorf("failed to marshal authorization event: %w", err)
}
auth := base64.StdEncoding.EncodeToString(evtj)
mediaserver := client.GetMediaServer()
mirrorURL := mediaserver + "mirror"
requestBody := map[string]string{"url": url}
requestJSON, _ := json.Marshal(requestBody)
req, err := http.NewRequestWithContext(ctx, "PUT", mirrorURL, bytes.NewReader(requestJSON))
if err != nil {
return nil, fmt.Errorf("failed to create mirror request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Nostr "+auth)
httpClient := &http.Client{}
mirrorResp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send mirror request: %w", err)
}
defer mirrorResp.Body.Close()
if mirrorResp.StatusCode < 200 || mirrorResp.StatusCode >= 300 {
body, _ := io.ReadAll(mirrorResp.Body)
return nil, fmt.Errorf("mirror request failed with HTTP %d: %s", mirrorResp.StatusCode, string(body))
}
var bd blossom.BlobDescriptor
if err := json.NewDecoder(mirrorResp.Body).Decode(&bd); err != nil {
return nil, fmt.Errorf("failed to decode blob descriptor: %w", err)
}
return &bd, nil
servers := c.StringSlice("server")
if err != nil {
return nil, err
}
return blossom.NewClient(servers[0], keyer), nil
}

377
bunker.go
View File

@@ -4,7 +4,10 @@ import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"maps"
"net"
"net/url"
"os"
"path/filepath"
@@ -42,15 +45,6 @@ var bunker = &cli.Command{
OnlyOnce: true,
Category: PERSISTENCE,
},
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as hex or nsec",
DefaultText: "the key '1'",
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
},
&cli.StringSliceFlag{
Name: "authorized-secrets",
Aliases: []string{"s"},
@@ -73,14 +67,8 @@ var bunker = &cli.Command{
},
Action: func(ctx context.Context, c *cli.Command) error {
// read config from file
config := struct {
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
}{
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
}
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
config := BunkerConfig{}
baseRelaysUrls := nostr.AppendUnique(c.Args().Slice(), c.StringSlice("relay")...)
for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url)
}
@@ -122,16 +110,16 @@ var bunker = &cli.Command{
persist = func() {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
log(color.RedString("failed to persist: %w\n"), err)
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: %w\n"), err)
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: %w\n"), err)
log(color.RedString("failed to persist: %s\n"), err)
os.Exit(4)
}
}
@@ -142,6 +130,15 @@ var bunker = &cli.Command{
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
}
@@ -149,8 +146,12 @@ var bunker = &cli.Command{
for i, url := range config.Relays {
config.Relays[i] = nostr.NormalizeURL(url)
}
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
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
@@ -167,14 +168,16 @@ var bunker = &cli.Command{
} else {
config.Secret = baseSecret
config.Relays = baseRelaysUrls
config.AuthorizedKeys = baseAuthorizedKeys
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
sec = defaultKey().Hex()
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
@@ -205,17 +208,24 @@ var bunker = &cli.Command{
// try to connect to the relays here
qs := url.Values{}
relayURLs := make([]string, 0, len(config.Relays))
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
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 relays {
relayURLs = append(relayURLs, relay.URL)
qs.Add("relay", relay.URL)
for _, relay := range config.Relays {
qs.Add("relay", relay)
}
if len(relayURLs) == 0 {
if len(relays) == 0 {
return fmt.Errorf("not connected to any relays: please specify at least one")
}
@@ -226,20 +236,41 @@ var bunker = &cli.Command{
// 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() {
qs.Set("secret", newSecret)
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
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.AuthorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range config.AuthorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
if len(config.Clients) != 0 {
authorizedKeysStr = "\n authorized clients:"
for _, c := range config.Clients {
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
name := ""
if c.Name != "" {
name = c.Name
if c.URL != "" {
name += " " + colors.underline(c.URL)
}
} else if c.URL != "" {
name = colors.underline(c.URL)
}
if name != "" {
authorizedKeysStr += " (" + name + ")"
}
}
}
@@ -249,8 +280,8 @@ var bunker = &cli.Command{
}
preauthorizedFlags := ""
for _, k := range config.AuthorizedKeys {
preauthorizedFlags += " -k " + k.Hex()
for _, c := range config.Clients {
preauthorizedFlags += " -k " + c.PubKey.Hex()
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
@@ -261,8 +292,8 @@ var bunker = &cli.Command{
secretKeyFlag = "--sec " + sec
}
relayURLsPossiblyWithoutSchema := make([]string, len(relayURLs))
for i, url := range relayURLs {
relayURLsPossiblyWithoutSchema := make([]string, len(config.Relays))
for i, url := range config.Relays {
if strings.HasPrefix(url, "wss://") {
relayURLsPossiblyWithoutSchema[i] = url[6:]
} else {
@@ -279,7 +310,7 @@ var bunker = &cli.Command{
)
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
colors.bold(relayURLs),
colors.bold(config.Relays),
colors.bold(pubkey.Hex()),
colors.bold(npub),
authorizedKeysStr,
@@ -290,7 +321,7 @@ var bunker = &cli.Command{
} else {
// otherwise just print the data
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n\n",
colors.bold(relayURLs),
colors.bold(config.Relays),
colors.bold(pubkey.Hex()),
colors.bold(npub),
authorizedKeysStr,
@@ -309,33 +340,42 @@ var bunker = &cli.Command{
printBunkerInfo()
// subscribe to relays
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
events := sys.Pool.SubscribeMany(ctx, allRelays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
signer := nip46.NewStaticKeySigner(sec)
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{}
signer.DefaultRelays = config.Relays
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
mu.Lock()
defer mu.Unlock()
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
if slices.Contains(authorizedSecrets, secret) {
// add client to authorized list for subsequent requests
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
if persist != nil {
persist()
}
}
return true
}
if secret == newSecret {
// store this key
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
// discard this and generate a new secret
newSecret = randString(12)
// print bunker info again after this
@@ -354,44 +394,55 @@ var bunker = &cli.Command{
return false
}
for ie := range events {
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 {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
continue
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(ie.Event.PubKey.Hex()), string(jreq))
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))
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
defer handlerWg.Done()
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
log("* sent response through %s\n", relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
}
}(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(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 {
@@ -403,6 +454,72 @@ var bunker = &cli.Command{
}()
}
// 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{
@@ -410,24 +527,44 @@ var bunker = &cli.Command{
Name: "connect",
Usage: "use the client-initiated NostrConnect flow of NIP46",
ArgsUsage: "<nostrconnect-uri>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "profile",
Usage: "profile name of the bunker to connect to",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("must be called with a nostrconnect://... uri")
}
uri, err := url.Parse(c.Args().First())
if err != nil || uri.Scheme != "nostrconnect" {
return fmt.Errorf("invalid uri")
if err := sendToSocket(c, c.Args().First()); err != nil {
return fmt.Errorf("failed to connect to running bunker: %w", err)
}
// TODO
return fmt.Errorf("this is not implemented yet")
return nil
},
},
},
}
type BunkerConfig struct {
Clients []BunkerConfigClient `json:"clients"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
// deprecated
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
}
type BunkerConfigClient struct {
PubKey nostr.PubKey `json:"pubkey"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
CustomRelays []string `json:"custom_relays,omitempty"`
}
type plainOrEncryptedKey struct {
Plain *nostr.SecretKey
Encrypted *string
@@ -495,3 +632,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
return true
}
func getSocketPath(c *cli.Command) string {
profile := "default"
if c.IsSet("profile") {
profile = c.String("profile")
}
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
}
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
res := make(chan *url.URL)
socketPath := getSocketPath(c)
// ensure directory exists
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
log(color.RedString("failed to create socket directory: %s\n", err))
return res
}
// delete existing socket file if it exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
log(color.RedString("failed to remove existing socket file: %s\n", err))
return res
}
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log(color.RedString("failed to listen on unix socket %s: %s\n", socketPath, err))
return res
}
go func() {
defer listener.Close()
defer os.Remove(socketPath) // cleanup socket file on exit
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
continue
}
}
go func(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
for {
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
break
}
uri, err := url.Parse(string(buf[:n]))
if err == nil && uri.Scheme == "nostrconnect" {
res <- uri
}
}
}(conn)
}
}()
return res
}
func sendToSocket(c *cli.Command, value string) error {
socketPath := getSocketPath(c)
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
}
defer conn.Close()
_, err = conn.Write([]byte(value))
if err != nil {
return fmt.Errorf("failed to send uri to bunker: %w", err)
}
return nil
}

View File

@@ -229,3 +229,17 @@ func TestNaturalTimestamps(t *testing.T) {
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)
}

203
count.go
View File

@@ -9,68 +9,26 @@ import (
"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 nip45 request (the flags are mostly the same as 'nak req').`,
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
},
ArgsUsage: "[relay...]",
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, nil, nostr.PoolOptions{})
relays := connectToAllRelays(
ctx,
c,
relayUrls,
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -84,95 +42,76 @@ var count = &cli.Command{
}
}
filter := nostr.Filter{}
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = authors
}
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
kinds := make([]nostr.Kind, len(kinds64))
for i, v := range kinds64 {
kinds[i] = nostr.Kind(v)
}
filter.Kinds = kinds
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
}
}
if c.IsSet("since") {
filter.Since = getNaturalDate(c, "since")
}
if c.IsSet("until") {
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = int(limit)
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
// go line by line from stdin or run once with input from flags
for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{}
if stdinFilter != "" {
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
continue
}
}
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " 📋"
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
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)
}
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
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 {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
exitIfLineProcessingError(ctx)
return nil
},
}

13
curl.go
View File

@@ -19,8 +19,10 @@ var curl = &cli.Command{
Name: "curl",
Usage: "calls curl but with a nip98 header",
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
Flags: defaultKeyFlags,
DisableSliceFlagSeparator: true,
// 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 {
@@ -120,13 +122,16 @@ func realCurl() error {
keyFlags = append(keyFlags, arg)
if arg != "--prompt-sec" {
i++
val := os.Args[i+2]
keyFlags = append(keyFlags, val)
if i+2 >= len(os.Args) {
break
}
keyFlags = append(keyFlags, os.Args[i+2])
}
} else {
curlFlags = append(curlFlags, arg)
}
}
return curl.Run(context.Background(), keyFlags)
// Run treats the first element as the program name, so prepend one
return curl.Run(context.Background(), append([]string{"nak curl"}, keyFlags...))
}

View File

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

View File

@@ -20,7 +20,7 @@ var dekey = &cli.Command{
Usage: "handles NIP-4E decoupled encryption keys",
Description: "maybe this picture will explain better than I can do here for now: https://cdn.azzamo.net/89c543d261ad0d665c1dea78f91e527c2e39e7fe503b440265a3c47e63c9139f.png",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
Flags: combineFlags([][]cli.Flag{},
&cli.StringFlag{
Name: "device",
Usage: "name of this device that will be published and displayed on other clients",
@@ -87,7 +87,7 @@ var dekey = &cli.Command{
// get relays for the user
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
relays := sys.FetchWriteRelays(ctx, userPub)
relayList := connectToAllRelays(ctx, c, relays, nil, nostr.PoolOptions{})
relayList := connectToAllRelays(ctx, c, relays)
if len(relayList) == 0 {
return fmt.Errorf("no relays to use")
}
@@ -164,7 +164,7 @@ var dekey = &cli.Command{
return fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
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")
@@ -236,12 +236,14 @@ var dekey = &cli.Command{
if err != nil {
continue
}
eSec, err = nostr.SecretKeyFromHex(eSecHex)
candidate, err := nostr.SecretKeyFromHex(eSecHex)
if err != nil {
continue
}
// check if it matches mainPub
if eSec.Public() == ePub {
// 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)

212
encode.go
View File

@@ -2,13 +2,67 @@ package main
import (
"context"
stdjson "encoding/json"
"fmt"
"iter"
"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",
@@ -21,14 +75,19 @@ var encode = &cli.Command{
nak encode nsec <privkey-hex>
echo '{"pubkey":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19","relays":["wss://nada.zero"]}' | nak encode
echo '{
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19"
"id":"7b225d32d3edb978dba1adfd9440105646babbabbda181ea383f74ba53c3be19",
"relays":["wss://nada.zero"],
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
} | nak encode`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 0 {
return nil
switch c.Args().First() {
case "naddr", "nevent", "npub", "nprofile", "nsec":
return nil
}
return fmt.Errorf("unknown encode target '%s'", c.Args().First())
}
relays := c.StringSlice("relay")
@@ -45,21 +104,18 @@ var encode = &cli.Command{
hasStdin = true
}
var eventPtr nostr.EventPointer
if err := json.Unmarshal([]byte(jsonStr), &eventPtr); err == nil && eventPtr.ID != nostr.ZeroID {
stdout(nip19.EncodeNevent(eventPtr.ID, appendUnique(relays, eventPtr.Relays...), eventPtr.Author))
if eventPtr, ok := parseEventPointerInput(jsonStr); ok {
stdout(nip19.EncodeNevent(eventPtr.ID, nostr.AppendUnique(relays, eventPtr.Relays...), eventPtr.Author))
continue
}
var profilePtr nostr.ProfilePointer
if err := json.Unmarshal([]byte(jsonStr), &profilePtr); err == nil && profilePtr.PublicKey != nostr.ZeroPK {
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, appendUnique(relays, profilePtr.Relays...)))
if entityPtr, ok := parseEntityPointerInput(jsonStr); ok {
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, nostr.AppendUnique(relays, entityPtr.Relays...)))
continue
}
var entityPtr nostr.EntityPointer
if err := json.Unmarshal([]byte(jsonStr), &entityPtr); err == nil && entityPtr.PublicKey != nostr.ZeroPK {
stdout(nip19.EncodeNaddr(entityPtr.PublicKey, entityPtr.Kind, entityPtr.Identifier, appendUnique(relays, entityPtr.Relays...)))
if profilePtr, ok := parseProfilePointerInput(jsonStr); ok {
stdout(nip19.EncodeNprofile(profilePtr.PublicKey, nostr.AppendUnique(relays, profilePtr.Relays...)))
continue
}
@@ -82,7 +138,7 @@ var encode = &cli.Command{
for target := range getStdinLinesOrArguments(c.Args()) {
pk, err := nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
continue
}
@@ -101,7 +157,7 @@ var encode = &cli.Command{
for target := range getStdinLinesOrArguments(c.Args()) {
sk, err := nostr.SecretKeyFromHex(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid private key '%s': %w", target, err)
ctx = lineProcessingError(ctx, "invalid private key '%s': %s", target, err)
continue
}
@@ -129,18 +185,25 @@ var encode = &cli.Command{
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
pk, err := nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %w", target, err)
continue
}
for target := range getEncodeSubcommandInput(c.Args(), false) {
relays := c.StringSlice("relay")
pk := nostr.ZeroPK
if profilePtr, ok := parseProfilePointerInput(target); ok {
pk = profilePtr.PublicKey
relays = nostr.AppendUnique(relays, profilePtr.Relays...)
} else {
var err error
pk, err = nostr.PubKeyFromHexCheap(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid public key '%s': %s", target, err)
continue
}
}
if getBoolInt(c, "outbox") > 0 {
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
relays = appendUnique(relays, r)
relays = nostr.AppendUnique(relays, r)
}
}
@@ -177,19 +240,29 @@ var encode = &cli.Command{
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
id, err := parseEventID(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue
}
for target := range getEncodeSubcommandInput(c.Args(), false) {
id := nostr.ZeroID
author := getPubKey(c, "author")
relays := c.StringSlice("relay")
if eventPtr, ok := parseEventPointerInput(target); ok {
id = eventPtr.ID
relays = nostr.AppendUnique(relays, eventPtr.Relays...)
if author == nostr.ZeroPK {
author = eventPtr.Author
}
} else {
var err error
id, err = parseEventID(target)
if err != nil {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue
}
}
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
relays = appendUnique(relays, r)
relays = nostr.AppendUnique(relays, r)
}
}
@@ -209,22 +282,19 @@ var encode = &cli.Command{
Usage: "generate codes for addressable events",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "identifier",
Aliases: []string{"d"},
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
Required: true,
Name: "identifier",
Aliases: []string{"d"},
Usage: "the \"d\" tag identifier of this replaceable event -- can also be read from stdin",
},
&PubKeyFlag{
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
Required: true,
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
},
&cli.IntFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
Required: true,
&KindFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
},
&cli.StringSliceFlag{
Name: "relay",
@@ -236,30 +306,68 @@ var encode = &cli.Command{
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,
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for d := range getStdinLinesOrBlank() {
for target := range getEncodeSubcommandInput(c.Args(), true) {
pubkey := getPubKey(c, "pubkey")
kind := getKind(c, "kind")
d := c.String("identifier")
relays := c.StringSlice("relay")
kind := c.Int("kind")
if kind < 30000 || kind >= 40000 {
return fmt.Errorf("kind must be between 30000 and 39999, got %d", kind)
if entityPtr, ok := parseEntityPointerInput(target); ok {
relays = nostr.AppendUnique(relays, entityPtr.Relays...)
if pubkey == nostr.ZeroPK {
pubkey = entityPtr.PublicKey
}
if kind == 0 {
kind = entityPtr.Kind
}
if !c.IsSet("identifier") {
d = entityPtr.Identifier
}
} else if target != "" {
d = target
}
if d == "" {
d = c.String("identifier")
if pubkey == nostr.ZeroPK {
ctx = lineProcessingError(ctx, "pubkey must be set")
continue
}
if kind == 0 {
ctx = lineProcessingError(ctx, "kind must be set")
continue
}
if kind.IsAddressable() {
if d == "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
ctx = lineProcessingError(ctx, "\"d\" tag identifier must be set for addressable events")
continue
}
} else if kind.IsReplaceable() {
if d != "" {
ctx = lineProcessingError(ctx, "\"d\" tag identifier must not be set for replaceable events")
continue
}
} else {
ctx = lineProcessingError(ctx, "can only encode addressable events")
continue
}
relays := c.StringSlice("relay")
if getBoolInt(c, "outbox") > 0 {
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
relays = appendUnique(relays, r)
relays = nostr.AppendUnique(relays, r)
}
}

View File

@@ -13,8 +13,7 @@ var encrypt = &cli.Command{
Usage: "encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64",
ArgsUsage: "[plaintext string]",
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
Flags: []cli.Flag{
&PubKeyFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
@@ -24,7 +23,7 @@ var encrypt = &cli.Command{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
),
},
Action: func(ctx context.Context, c *cli.Command) error {
target := getPubKey(c, "recipient-pubkey")
@@ -75,8 +74,7 @@ var decrypt = &cli.Command{
Usage: "decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext",
ArgsUsage: "[ciphertext base64]",
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
Flags: []cli.Flag{
&PubKeyFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey", "from"},
@@ -86,7 +84,7 @@ var decrypt = &cli.Command{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
),
},
Action: func(ctx context.Context, c *cli.Command) error {
source := getPubKey(c, "sender-pubkey")
@@ -123,7 +121,7 @@ var decrypt = &cli.Command{
res, err := kr.Decrypt(ctx, ciphertext, source)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
return fmt.Errorf("failed to decrypt: %w", err)
}
stdout(res)
}

225
event.go
View File

@@ -13,6 +13,7 @@ import (
"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/urfave/cli/v3"
@@ -22,6 +23,7 @@ const (
CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
CATEGORY_SIGNER = "SIGNER OPTIONS"
CATEGORY_EXTRAS = "EXTRAS"
CATEGORY_AUTH = "AUTH"
)
var eventCmd = &cli.Command{
@@ -39,7 +41,7 @@ example:
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak event wss://offchain.pub
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
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{
@@ -66,6 +68,18 @@ example:
Hidden: true,
},
// ~~~
&cli.BoolFlag{
Name: "force-sign",
Usage: "when an event is already signed and not modified it isn't signed again even when a different --sec is given, this option negates that",
Value: false,
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "no-sign",
Usage: "print the event without signing it, using the specified pubkey",
Value: false,
Category: CATEGORY_SIGNER,
},
&cli.UintFlag{
Name: "pow",
Usage: "nip13 difficulty to target when doing hash work on the event id",
@@ -76,9 +90,14 @@ example:
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
Category: CATEGORY_EXTRAS,
},
&cli.StringFlag{
Name: "jq",
Usage: "filter returned events with jq expression",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Name: "jq-raw",
Usage: "print --jq string results without JSON quoting, like `jq -r`",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
@@ -86,14 +105,26 @@ example:
Usage: "print the nevent code (to stderr) after the event is published",
Category: CATEGORY_EXTRAS,
},
&cli.UintFlag{
&cli.BoolFlag{
Name: "outbox",
Usage: "publish to the \"write\" relays of the author and to the \"read\" relays of anyone mentioned in \"p\" tags",
DefaultText: "false, will only use manually-specified relays",
Category: CATEGORY_EXTRAS,
},
&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"},
@@ -123,6 +154,11 @@ example:
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringSliceFlag{
Name: "h",
Usage: "shortcut for --tag h=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&NaturalTimeFlag{
Name: "created-at",
Aliases: []string{"time", "ts"},
@@ -136,31 +172,31 @@ example:
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(ctx context.Context, c *cli.Command) error {
// try to connect to the relays here
var relays []*nostr.Relay
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
}
argRelayUrls := c.Args().Slice()
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
// then process input and generate events:
// will reuse this
@@ -169,6 +205,11 @@ example:
// 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"`)
@@ -178,8 +219,8 @@ example:
return fmt.Errorf("invalid event received from stdin: %s", err)
}
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
evt.Kind = nostr.Kind(kind)
if c.IsSet("kind") {
evt.Kind = getKind(c, "kind")
mustRehashAndResign = true
} else if !kindWasSupplied {
evt.Kind = 1
@@ -212,8 +253,12 @@ example:
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
val := tagValues[0]
if len(tagName) == 1 {
val = decodeTagValue(val, rune(tagName[0]))
}
if len(tagValues) >= 1 {
tagValues[0] = decodeTagValue(tagValues[0])
tagValues[0] = val
}
tag = append(tag, tagValues...)
}
@@ -221,21 +266,45 @@ example:
}
for _, etag := range c.StringSlice("e") {
decodedEtag := decodeTagValue(etag)
decodedEtag := decodeTagValue(etag, 'e')
if tags.FindWithValue("e", decodedEtag) == nil {
tags = append(tags, nostr.Tag{"e", decodedEtag})
}
}
for _, ptag := range c.StringSlice("p") {
decodedPtag := decodeTagValue(ptag)
decodedPtag := decodeTagValue(ptag, 'p')
if tags.FindWithValue("p", decodedPtag) == nil {
tags = append(tags, nostr.Tag{"p", decodedPtag})
}
}
for _, dtag := range c.StringSlice("d") {
decodedDtag := decodeTagValue(dtag)
if tags.FindWithValue("d", decodedDtag) == nil {
tags = append(tags, nostr.Tag{"d", decodedDtag})
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 {
@@ -244,6 +313,9 @@ example:
}
mustRehashAndResign = true
}
if authorPubKey != nostr.ZeroPK {
evt.PubKey = authorPubKey
}
if c.IsSet("created-at") {
evt.CreatedAt = getNaturalDate(c, "created-at")
@@ -253,7 +325,7 @@ example:
mustRehashAndResign = true
}
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
if c.IsSet("musig") || c.Bool("force-sign") {
mustRehashAndResign = true
}
@@ -268,7 +340,7 @@ example:
if err != nil {
return err
}
} else {
} else if evt.PubKey == nostr.ZeroPK {
evt.PubKey, _ = kr.GetPublicKey(ctx)
}
@@ -279,7 +351,13 @@ example:
mustRehashAndResign = true
}
if evt.Sig == [64]byte{} || mustRehashAndResign {
if c.Bool("no-sign") {
if evt.PubKey == nostr.ZeroPK {
return fmt.Errorf("--no-sign requires a pubkey in the event or via --author")
}
evt.ID = nostr.ZeroID
evt.Sig = [64]byte{}
} else if evt.Sig == [64]byte{} || mustRehashAndResign {
if numSigners := c.Uint("musig"); numSigners > 1 {
// must do musig
pubkeys := c.StringSlice("musig-pubkey")
@@ -304,16 +382,62 @@ example:
}
}
// print event as json
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)
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)
}
}
}
// 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)
}
stdout(result)
} else {
out, matches, err := jq(evt)
if err != nil {
return fmt.Errorf("jq filter failed: %w", err)
}
if matches {
stdout(out)
}
}
stdout(result)
return publishFlow(ctx, c, kr, evt, relays)
}
@@ -340,7 +464,8 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
if c.Bool("confirm") {
relaysStr := make([]string, len(relays))
for i, r := range relays {
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
_, after, _ := strings.Cut(r.URL, "://")
relaysStr[i] = strings.ToLower(after)
}
time.Sleep(time.Millisecond * 10)
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
@@ -418,9 +543,19 @@ func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr
defer cancel()
if !relay.IsConnected() {
if new_, err := sys.Pool.EnsureRelay(relay.URL); err == nil {
relays[i] = new_
relay = new_
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_
}
}
}

View File

@@ -18,15 +18,28 @@ var fetch = &cli.Command{
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
DisableSliceFlagSeparator: true,
Flags: append(reqFilterFlags,
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&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
}
for code := range getStdinLinesOrArguments(c.Args()) {
filter := nostr.Filter{}
var authorHint nostr.PubKey
@@ -61,7 +74,7 @@ var fetch = &cli.Command{
}
relays = append(relays, v.Relays...)
case "note":
filter.IDs = append(filter.IDs, value.([32]byte))
filter.IDs = append(filter.IDs, value.(nostr.EventPointer).ID)
case "naddr":
v := value.(nostr.EntityPointer)
filter.Kinds = []nostr.Kind{v.Kind}
@@ -88,7 +101,7 @@ var fetch = &cli.Command{
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
}
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 3) {
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 6) {
relays = append(relays, url)
}
}
@@ -106,10 +119,29 @@ var fetch = &cli.Command{
continue
}
found := false
for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-fetch",
}) {
stdout(ie.Event)
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)
}
}

View File

@@ -19,8 +19,17 @@ example:
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
`,
DisableSliceFlagSeparator: true,
Flags: reqFilterFlags,
ArgsUsage: "[event_json] [base_filter_json]",
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()
@@ -54,6 +63,11 @@ example:
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
@@ -72,6 +86,9 @@ example:
if evt.Sig == [64]byte{} {
evt.Sig = baseEvent.Sig
}
if evt.Kind == 0 {
evt.Kind = baseEvent.Kind
}
if evt.Content == "" {
evt.Content = baseEvent.Content
}
@@ -83,7 +100,20 @@ example:
}
if baseFilter.Matches(evt) {
stdout(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)
}

153
flags.go
View File

@@ -105,10 +105,10 @@ func (t *naturalTimeValue) Set(value string) error {
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, value)
ts = date.Time
if err != nil {
return err
}
ts = date.Time
}
if t.timestamp != nil {
@@ -131,6 +131,45 @@ func getNaturalDate(cmd *cli.Command, name string) 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]
)
@@ -183,6 +222,66 @@ func getPubKeySlice(cmd *cli.Command, name string) []nostr.PubKey {
//
//
type PubKeyOrAddress struct {
PubKey nostr.PubKey
Addr *nostr.EntityPointer
}
type (
pubKeyOrAddressValue struct {
value PubKeyOrAddress
hasBeenSet bool
}
pubKeyOrAddressSlice = cli.SliceBase[PubKeyOrAddress, struct{}, pubKeyOrAddressValue]
PubKeyOrAddressFlag = cli.FlagBase[[]PubKeyOrAddress, struct{}, pubKeyOrAddressSlice]
)
var _ cli.ValueCreator[PubKeyOrAddress, struct{}] = pubKeyOrAddressValue{}
func (t pubKeyOrAddressValue) Create(val PubKeyOrAddress, p *PubKeyOrAddress, c struct{}) cli.Value {
*p = val
return &pubKeyOrAddressValue{
value: val,
}
}
func (t pubKeyOrAddressValue) ToString(b PubKeyOrAddress) string {
if b.Addr != nil {
return b.Addr.AsTagReference()
}
return b.PubKey.String()
}
func (t *pubKeyOrAddressValue) Set(value string) error {
pubkey, err1 := parsePubKey(value)
if err1 == nil {
t.value = PubKeyOrAddress{PubKey: pubkey}
t.hasBeenSet = true
return nil
}
addr, err2 := nostr.ParseAddrString(value)
if err2 == nil {
t.value = PubKeyOrAddress{Addr: &addr}
t.hasBeenSet = true
return nil
}
return fmt.Errorf("value is neither a pubkey or an address: %w; %w", err1, err2)
}
func (t *pubKeyOrAddressValue) String() string { return fmt.Sprintf("%#v", t.value) }
func (t *pubKeyOrAddressValue) Value() PubKeyOrAddress { return t.value }
func (t *pubKeyOrAddressValue) Get() any { return t.value }
func getPubKeyOrAddressSlice(cmd *cli.Command, name string) []PubKeyOrAddress {
return cmd.Value(name).([]PubKeyOrAddress)
}
//
//
//
type (
IDFlag = cli.FlagBase[nostr.ID, struct{}, idValue]
)
@@ -229,3 +328,55 @@ type (
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)
}

54
fs.go
View File

@@ -1,4 +1,4 @@
//go:build !windows && !openbsd
//go:build !windows && !openbsd && !cgofuse
package main
@@ -13,8 +13,10 @@ import (
"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"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
@@ -22,7 +24,7 @@ var fsCmd = &cli.Command{
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: append(defaultKeyFlags,
Flags: combineFlags([][]cli.Flag{},
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
@@ -62,7 +64,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3
}
root := NewFSRoot(
root := nostrfs.NewNostrRoot(
context.WithValue(
context.WithValue(
ctx,
@@ -73,7 +75,7 @@ var fsCmd = &cli.Command{
sys,
kr,
mountpoint,
FSOptions{
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
@@ -81,22 +83,21 @@ var fsCmd = &cli.Command{
// 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...)
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)
}
go func() {
host.Mount("", mountArgs)
}()
log("ok.\n")
// setup signal handling for clean unmount
@@ -106,12 +107,17 @@ var fsCmd = &cli.Command{
go func() {
<-ch
log("- unmounting... ")
// cgofuse doesn't have explicit unmount, it unmounts on process exit
log("ok\n")
chErr <- nil
err := server.Unmount()
if err != nil {
chErr <- fmt.Errorf("unmount failed: %w", err)
} else {
log("ok\n")
chErr <- nil
}
}()
// wait for signals
// 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
},
}

View File

@@ -12,6 +12,7 @@ import (
"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"
)
@@ -21,7 +22,7 @@ var fsCmd = &cli.Command{
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `(experimental)`,
ArgsUsage: "<mountpoint>",
Flags: append(defaultKeyFlags,
Flags: combineFlags([][]cli.Flag{},
&PubKeyFlag{
Name: "pubkey",
Usage: "public key from where to to prepopulate directories",
@@ -61,7 +62,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3
}
root := NewFSRoot(
root := nostrfs.NewNostrRoot(
context.WithValue(
context.WithValue(
ctx,
@@ -72,7 +73,7 @@ var fsCmd = &cli.Command{
sys,
kr,
mountpoint,
FSOptions{
nostrfs.Options{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},

32
gift.go
View File

@@ -24,7 +24,6 @@ var gift = &cli.Command{
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "wrap",
@@ -179,7 +178,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
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 --use-direct to bypass", err)
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
@@ -209,9 +208,12 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
for c, potentialCipher := range ciphers {
switch c {
case 0:
log("- trying the receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
case 1:
log("- trying the receiver's identity key %s\n", color.CyanString(receiver.Hex()))
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)
@@ -227,7 +229,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
cipher = potentialCipher
break
}
if seal.ID == nostr.ZeroID {
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)
}
@@ -236,6 +238,10 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
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)
@@ -247,11 +253,15 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
var rumor nostr.Event
err = nil
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
if senderEncryptionPublicKey == nostr.ZeroPK {
continue
}
switch s {
case 0:
log("- trying the sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
case 1:
log("- trying the sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
}
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
@@ -267,10 +277,13 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
break
}
if rumor.ID == nostr.ZeroID {
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())
}
@@ -319,9 +332,8 @@ func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pub
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
}
if eSec.Public() != ePub {
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
return [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
}
}

2435
git.go

File diff suppressed because it is too large Load Diff

21
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
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
@@ -12,12 +12,11 @@ require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.16.0
github.com/json-iterator/go v1.1.12
github.com/liamg/magic v0.0.1 // indirect
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/mattn/go-tty v0.0.7
github.com/mdp/qrterminal/v3 v3.2.1
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/stretchr/testify v1.10.0
@@ -28,6 +27,15 @@ require (
golang.org/x/term v0.32.0
)
require (
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
@@ -49,6 +57,8 @@ require (
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/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
@@ -64,6 +74,7 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/itchyny/timefmt-go v0.1.8 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
@@ -71,7 +82,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/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
@@ -97,7 +108,7 @@ require (
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.35.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

44
go.sum
View File

@@ -1,5 +1,7 @@
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
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=
@@ -81,6 +83,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/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=
@@ -96,6 +102,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/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=
@@ -142,11 +150,17 @@ github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnm
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=
@@ -165,8 +179,12 @@ 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=
@@ -184,16 +202,18 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=
github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k=
github.com/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=
@@ -284,6 +304,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632
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=
@@ -292,11 +313,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.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=
@@ -313,11 +336,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
@@ -325,11 +350,14 @@ 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=

1153
group.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,26 +7,29 @@ import (
"fmt"
"iter"
"math/rand"
"net/http"
"net/textproto"
"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/nip42"
"fiatjaf.com/nostr/schema"
"fiatjaf.com/nostr/sdk"
"github.com/chzyer/readline"
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/mattn/go-isatty"
"github.com/mattn/go-tty"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
@@ -45,6 +48,8 @@ var (
stdout = func(args ...any) { fmt.Fprintln(color.Output, args...) }
)
var connectTimeout = 2 * time.Second
func isPiped() bool {
stat, err := os.Stdin.Stat()
if err != nil {
@@ -61,6 +66,7 @@ func getJsonsOrBlank() iter.Seq[string] {
var finalJsonErr error
return func(yield func(string) bool) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
// we'll try the next line until we're successful
@@ -75,6 +81,7 @@ func getJsonsOrBlank() iter.Seq[string] {
finalJsonErr = nil
if !yield(stdinEvent) {
stopped = true
return false
}
@@ -82,8 +89,14 @@ func getJsonsOrBlank() iter.Seq[string] {
return true
})
if stopped {
return
}
if !hasStdin {
yield("{}")
if !yield("{}") {
return
}
}
if finalJsonErr != nil {
@@ -94,15 +107,23 @@ func getJsonsOrBlank() iter.Seq[string] {
func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
stopped = true
return false
}
return true
})
if stopped {
return
}
if !hasStdin {
yield("")
if !yield("") {
return
}
}
}
}
@@ -168,24 +189,17 @@ func connectToAllRelays(
ctx context.Context,
c *cli.Command,
relayUrls []string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error), // if this exists we will force preauth
opts nostr.PoolOptions,
) []*nostr.Relay {
// first pass to check if these are valid relay URLs
for _, url := range relayUrls {
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
for i, url := range relayUrls {
url = nostr.NormalizeURL(url)
relayUrls[i] = url
if !nostr.IsValidRelayURL(url) {
log("invalid relay URL: %s\n", url)
os.Exit(4)
}
}
opts.EventMiddleware = sys.TrackEventHints
opts.PenaltyBox = true
opts.RelayOptions = nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}},
}
sys.Pool = nostr.NewPool(opts)
relays := make([]*nostr.Relay, 0, len(relayUrls))
if supportsDynamicMultilineMagic() {
@@ -207,21 +221,28 @@ func connectToAllRelays(
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, preAuthSigner, colorizepreamble, logthis)
relay := connectToSingleRelay(ctx, c, url, colorizepreamble, logthis)
if relay != nil {
mu.Lock()
relays = append(relays, relay)
mu.Unlock()
}
wg.Done()
}()
@@ -230,8 +251,8 @@ func connectToAllRelays(
} else {
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(url))
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
relay := connectToSingleRelay(ctx, c, url, nil, log)
if relay != nil {
relays = append(relays, relay)
}
@@ -246,67 +267,78 @@ func connectToSingleRelay(
ctx context.Context,
c *cli.Command,
url string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error),
colorizepreamble func(c func(string, ...any) string),
logthis func(s string, args ...any),
) *nostr.Relay {
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
if preAuthSigner != nil {
if colorizepreamble != nil {
colorizepreamble(color.YellowString)
}
logthis("waiting for auth challenge... ")
time.Sleep(time.Millisecond * 200)
nm := nostr.NormalizeURL(url)
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
}); err == nil {
// auth succeeded
goto preauthSuccess
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second)
continue
} else {
// it failed for some other reason, so skip this relay
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis(err.Error())
return nil
}
}
}
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("failed to get an AUTH challenge in enough time.")
logthis(clampError(err, len(url)+12))
return nil
}
preauthSuccess:
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(colors.successf)
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
}
}
}
logthis("ok.")
return relay
} else {
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
// if we're here that means we've failed to connect, this may be a huge message
// but we're likely to only be interested in the lowest level error (although we can leave space)
logthis(clampError(err, len(url)+12))
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) {
@@ -338,29 +370,6 @@ func supportsDynamicMultilineMagic() bool {
return true
}
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent *nostr.Event) (err error) {
defer func() {
if err != nil {
cleanUrl, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
log("%s auth failed: %s", colors.errorf(cleanUrl), err)
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth required, but --auth flag not given")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
return kr.SignEvent(ctx, authEvent)
}
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
log(msg+"\n", args...)
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
@@ -415,19 +424,6 @@ func clampError(err error, prefixAlreadyPrinted int) string {
return msg
}
func appendUnique[A comparable](list []A, newEls ...A) []A {
ex:
for _, newEl := range newEls {
for _, el := range list {
if el == newEl {
continue ex
}
}
list = append(list, newEl)
}
return list
}
func askConfirmation(msg string) bool {
if isPiped() {
tty, err := tty.Open()
@@ -472,15 +468,14 @@ func askConfirmation(msg string) bool {
}
func parsePubKey(value string) (nostr.PubKey, error) {
// try nip05 first
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err == nil {
return pp.PublicKey, nil
if err != nil {
return nostr.ZeroPK, err
}
// if nip05 fails, fall through to try as pubkey
return pp.PublicKey, nil
}
pk, err := nostr.PubKeyFromHex(value)
@@ -504,6 +499,19 @@ func parsePubKey(value string) (nostr.PubKey, error) {
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 {
@@ -513,8 +521,8 @@ func parseEventID(value string) (nostr.ID, error) {
if prefix, decoded, err := nip19.Decode(value); err == nil {
switch prefix {
case "note":
if id, ok := decoded.(nostr.ID); ok {
return id, nil
if event, ok := decoded.(nostr.EventPointer); ok {
return event.ID, nil
}
case "nevent":
if event, ok := decoded.(nostr.EventPointer); ok {
@@ -526,8 +534,23 @@ func parseEventID(value string) (nostr.ID, error) {
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
}
func decodeTagValue(value string) string {
if strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1") || strings.HasPrefix(value, "nprofile1") || strings.HasPrefix(value, "naddr1") {
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()
}
@@ -535,24 +558,167 @@ func decodeTagValue(value string) string {
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
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
underline func(...any) string
underlinef func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{
color.New(color.Reset).Print,
color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf,
color.New(color.Underline).Sprint,
color.New(color.Underline).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,
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
}

View File

@@ -2,45 +2,35 @@ package main
import (
"context"
"crypto/sha256"
"fmt"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip19"
"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"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
)
var defaultKey = nostr.KeyOne.Hex()
// 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
}
var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
DefaultText: "the key '01'",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: defaultKey,
HideDefault: true,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to use when communicating with nip46 bunkers",
DefaultText: "a random key",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
},
return sha256.Sum256([]byte(mid))
}
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, nostr.SecretKey, error) {
@@ -50,10 +40,10 @@ func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer,
}
var kr nostr.Keyer
if bunker != nil {
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
} else {
if bunker == nil {
kr = keyer.NewPlainKeySigner(key)
} else {
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
}
return kr, key, nil
@@ -61,25 +51,15 @@ func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer,
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (nostr.SecretKey, *nip46.BunkerClient, error) {
sec := c.String("sec")
if strings.HasPrefix(sec, "bunker://") {
// it's a bunker
bunkerURL := sec
clientKeyHex := c.String("connect-as")
var clientKey nostr.SecretKey
if clientKeyHex != "" {
var err error
clientKey, err = nostr.SecretKeyFromHex(clientKeyHex)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("bunker client key '%s' is invalid: %w", clientKeyHex, err)
}
} else {
clientKey = nostr.Generate()
}
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, nil, func(s string) {
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 {
@@ -105,16 +85,37 @@ func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (
return sk, nil, nil
}
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
return ski.(nostr.SecretKey), 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")
}
sk, err := nostr.SecretKeyFromHex(sec)
if err != nil {
return nostr.SecretKey{}, nil, fmt.Errorf("invalid secret key: %w", err)
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
}
}
return sk, nil, nil
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) {

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

141
key.go
View File

@@ -23,9 +23,12 @@ var key = &cli.Command{
Commands: []*cli.Command{
generate,
public,
expand,
encryptKey,
decryptKey,
combine,
validate,
defaultCommand,
},
}
@@ -54,7 +57,21 @@ var public = &cli.Command{
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
args := c.Args().Slice()
if len(args) == 0 && !isPiped() {
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)
}
stdout(pk.Hex())
return nil
}
for sk := range getSecretKeysFromStdinLinesOrSlice(ctx, c, args) {
_, pk := btcec.PrivKeyFromBytes(sk[:])
if c.Bool("with-parity") {
@@ -67,6 +84,37 @@ var public = &cli.Command{
},
}
var expand = &cli.Command{
Name: "expand",
Usage: "left-pads a hex key to 64 characters",
Description: ``,
ArgsUsage: "[key]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for keyhex := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
keyhex = strings.TrimSpace(keyhex)
if keyhex == "" {
continue
}
if len(keyhex) > 64 {
ctx = lineProcessingError(ctx, "invalid hex key: length %d", len(keyhex))
continue
}
check := keyhex
if len(check)%2 == 1 {
check = "0" + check
}
if _, err := hex.DecodeString(check); err != nil {
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
continue
}
keyhex = strings.ToLower(keyhex)
stdout(strings.Repeat("0", 64-len(keyhex)) + keyhex)
}
return nil
},
}
var encryptKey = &cli.Command{
Name: "encrypt",
Usage: "encrypts a secret key and prints an ncryptsec code",
@@ -139,7 +187,8 @@ var decryptKey = &cli.Command{
}
} else {
password = c.Args().Get(0)
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
// 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)
@@ -147,6 +196,7 @@ var decryptKey = &cli.Command{
}
stdout(sk.Hex())
}
exitIfLineProcessingError(ctx)
return nil
}
default:
@@ -155,6 +205,64 @@ var decryptKey = &cli.Command{
},
}
var validate = &cli.Command{
Name: "validate",
Usage: "validates a public key by attempting to parse it",
Description: `Accepts public keys in hex (64 characters) or npub format.
Returns error if key is invalid, otherwise exits successfully.`,
ArgsUsage: "[pubkey...]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for pk := range getStdinLinesOrArgumentsFromSlice(c.Args().Slice()) {
pk = strings.TrimSpace(pk)
if pk == "" {
continue
}
var pkBytes []byte
var err error
if strings.HasPrefix(pk, "npub1") {
_, data, err := nip19.Decode(pk)
if err != nil {
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
continue
}
tmp := data.(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
}
}
return nil
},
}
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",
@@ -183,15 +291,13 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
for i, keyhex := range result.Keys {
keyb, err := hex.DecodeString(keyhex)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
pk32, err := parsePubKey(keyhex)
if err == nil { /* we'll try both the 02 and the 03 prefix versions */
result.Keys[i] = pk32.Hex()
if len(keyb) == 32 /* we'll use both the 02 and the 03 prefix versions */ {
group := make([]*btcec.PublicKey, 2)
for i, prefix := range []byte{0x02, 0x03} {
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, pk32[:]...))
if err != nil {
log("error parsing key %s: %s", keyhex, err)
continue
@@ -199,7 +305,16 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
group[i] = pubk
}
keyGroups = append(keyGroups, group)
} else /* assume it's 33 */ {
continue
}
keyb, err := hex.DecodeString(keyhex)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
if len(keyb) == 33 {
result.Keys[i] = hex.EncodeToString(keyb[1:])
pubk, err := btcec.ParsePubKey(keyb)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
@@ -208,7 +323,15 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
// remove the leading byte from the output just so it is all uniform
result.Keys[i] = result.Keys[i][2:]
continue
}
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)

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
},
}

45
lmdb.go
View File

@@ -16,28 +16,31 @@ import (
func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
configPath := c.String("config-path")
if configPath != "" {
hintsPath := filepath.Join(configPath, "outbox/hints")
os.MkdirAll(hintsPath, 0755)
_, err := lmdbh.NewLMDBHints(hintsPath)
if err != nil {
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
}
if configPath == "" {
return
}
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{}
}
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
}
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
}
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
}
}

179
main.go
View File

@@ -6,13 +6,56 @@ import (
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
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 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
@@ -33,6 +76,7 @@ var app = &cli.Command{
decode,
encode,
key,
kindCmd,
verify,
relay,
admin,
@@ -49,65 +93,96 @@ var app = &cli.Command{
curl,
fsCmd,
publish,
nsite,
git,
group,
podcast,
nip,
syncCmd,
spell,
profile,
validateCmd,
},
Version: version,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config-path",
Hidden: true,
Value: (func() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config/nak")
} else {
return ""
}
})(),
},
&cli.BoolFlag{
Name: "quiet",
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) {}
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,
},
},
&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
},
},
},
defaultKeyFlags,
authFlags,
}),
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
sys = sdk.NewSystem()
setupLocalDatabases(c, sys)
sys.Pool = nostr.NewPool(nostr.PoolOptions{
AuthorKindQueryMiddleware: sys.TrackQueryAttempts,
EventMiddleware: sys.TrackEventHints,
RelayOptions: nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
},
})
sys.Pool.QueryMiddleware = sys.TrackQueryAttempts
sys.Pool.EventMiddleware = sys.TrackEventHints
sys.Pool.RelayOptions = nostr.RelayOptions{
RequestHeader: http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}},
}
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
},
@@ -118,6 +193,12 @@ func init() {
Name: "version",
Usage: "prints the version",
}
cli.HelpFlag = &cli.BoolFlag{
Name: "help",
Usage: "shows help",
HideDefault: true,
Local: true,
}
}
func main() {
@@ -126,9 +207,7 @@ func main() {
// a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil {
if err != nil {
log(color.YellowString(err.Error()) + "\n")
}
log(color.YellowString(err.Error()) + "\n")
colors.reset()
os.Exit(1)
}
@@ -136,9 +215,7 @@ func main() {
}
if err := app.Run(context.Background(), os.Args); err != nil {
if err != nil {
log("%s\n", color.RedString(err.Error()))
}
log("%s\n", color.RedString(err.Error()))
colors.reset()
os.Exit(1)
}

156
mcp.go
View File

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

18
nip.go
View File

@@ -5,12 +5,14 @@ import (
"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 {
@@ -63,7 +65,6 @@ examples:
}
url := "https://github.com/nostr-protocol/nips/blob/master/" + foundLink
fmt.Println("Opening " + url)
var cmd *exec.Cmd
switch runtime.GOOS {
@@ -129,12 +130,23 @@ examples:
}
// render markdown
rendered, err := glamour.Render(string(body), "auto")
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)
}
fmt.Print(rendered)
stdout(rendered)
return nil
},
}

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
}

View File

@@ -1,6 +1,4 @@
//go:build !openbsd
package main
package nostrfs
import (
"context"
@@ -19,18 +17,18 @@ import (
"github.com/winfsp/cgofuse/fuse"
)
type FSOptions struct {
type Options struct {
AutoPublishNotesTimeout time.Duration
AutoPublishArticlesTimeout time.Duration
}
type FSRoot struct {
type NostrRoot struct {
fuse.FileSystemBase
ctx context.Context
sys *sdk.System
rootPubKey nostr.PubKey
signer nostr.Signer
opts FSOptions
opts Options
mountpoint string
mu sync.RWMutex
@@ -53,9 +51,9 @@ type FSNode struct {
loaded bool
}
var _ fuse.FileSystemInterface = (*FSRoot)(nil)
var _ fuse.FileSystemInterface = (*NostrRoot)(nil)
func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o FSOptions) *FSRoot {
func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o Options) *NostrRoot {
var system *sdk.System
if sys != nil {
system = sys.(*sdk.System)
@@ -73,7 +71,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin
abs, _ := filepath.Abs(mountpoint)
root := &FSRoot{
root := &NostrRoot{
ctx: ctx,
sys: system,
rootPubKey: pubkey,
@@ -103,7 +101,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin
return root
}
func (r *FSRoot) initialize() {
func (r *NostrRoot) initialize() {
if r.rootPubKey == nostr.ZeroPK {
return
}
@@ -148,7 +146,7 @@ func (r *FSRoot) initialize() {
r.nodes["/"].children["@me"] = meNode
}
func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil {
return
@@ -177,7 +175,7 @@ func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
}
}
func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil || pm.Picture == "" {
return
@@ -258,7 +256,7 @@ func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
}
}
func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
defer cancel()
@@ -357,7 +355,7 @@ func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
}
}
func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
// use event ID first 8 chars + extension based on kind
ext := kindToExtension(evt.Kind)
@@ -393,14 +391,14 @@ func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
return fmt.Sprintf("%s.%s", idHex, ext)
}
func (r *FSRoot) getLog() func(string, ...interface{}) {
func (r *NostrRoot) getLog() func(string, ...interface{}) {
if log := r.ctx.Value("log"); log != nil {
return log.(func(string, ...interface{}))
}
return func(string, ...interface{}) {}
}
func (r *FSRoot) getNode(path string) *FSNode {
func (r *NostrRoot) getNode(path string) *FSNode {
originalPath := path
// normalize path
@@ -453,7 +451,7 @@ func (r *FSRoot) getNode(path string) *FSNode {
return node
}
func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
node := r.getNode(path)
// if node doesn't exist, try dynamic lookup
@@ -482,7 +480,7 @@ func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
}
// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths
func (r *FSRoot) dynamicLookup(path string) bool {
func (r *NostrRoot) dynamicLookup(path string) bool {
// normalize path
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
@@ -537,7 +535,7 @@ func (r *FSRoot) dynamicLookup(path string) bool {
}
}
func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
dirPath := "/" + npub
// check if already exists
@@ -630,7 +628,7 @@ func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer no
go r.fetchProfilePicture(dirPath, pubkey)
}
func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
dirPath := parentPath + "/" + name
// check if already exists
@@ -658,7 +656,7 @@ func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filte
go r.fetchEvents(dirPath, filter)
}
func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
dirPath := "/" + name
// fetch the event
@@ -739,7 +737,7 @@ func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) b
return true
}
func (r *FSRoot) Readdir(path string,
func (r *NostrRoot) Readdir(path string,
fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
ofst int64,
fh uint64,
@@ -770,7 +768,7 @@ func (r *FSRoot) Readdir(path string,
return 0
}
func (r *FSRoot) Open(path string, flags int) (int, uint64) {
func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
// log the open attempt
if r.ctx.Value("logverbose") != nil {
logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
@@ -801,7 +799,7 @@ func (r *FSRoot) Open(path string, flags int) (int, uint64) {
return 0, node.ino
}
func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path)
if node == nil || node.isDir {
return -fuse.ENOENT
@@ -820,7 +818,7 @@ func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
return n
}
func (r *FSRoot) Opendir(path string) (int, uint64) {
func (r *NostrRoot) Opendir(path string) (int, uint64) {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT, ^uint64(0)
@@ -831,16 +829,16 @@ func (r *FSRoot) Opendir(path string) (int, uint64) {
return 0, node.ino
}
func (r *FSRoot) Release(path string, fh uint64) int {
func (r *NostrRoot) Release(path string, fh uint64) int {
return 0
}
func (r *FSRoot) Releasedir(path string, fh uint64) int {
func (r *NostrRoot) Releasedir(path string, fh uint64) int {
return 0
}
// Create creates a new file
func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
// parse path
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
@@ -884,7 +882,7 @@ func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
}
// Truncate truncates a file
func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT
@@ -913,7 +911,7 @@ func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
}
// Write writes data to a file
func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT
@@ -957,7 +955,7 @@ func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
return n
}
func (r *FSRoot) publishNote(path string) {
func (r *NostrRoot) publishNote(path string) {
r.mu.Lock()
node, ok := r.nodes[path]
if !ok {
@@ -998,10 +996,13 @@ func (r *FSRoot) publishNote(path string) {
}
for _, url := range relays {
relay, err := r.sys.Pool.EnsureRelay(url)
connectCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
relay, err := nostr.RelayConnect(connectCtx, url, r.sys.Pool.RelayOptions)
cancel()
if err != nil {
continue
}
r.sys.Pool.Relays.Store(nostr.NormalizeURL(url), relay)
relay.Publish(ctx, *evt)
}
@@ -1034,7 +1035,7 @@ func (r *FSRoot) publishNote(path string) {
}
// Unlink deletes a file
func (r *FSRoot) Unlink(path string) int {
func (r *NostrRoot) Unlink(path string) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1067,7 +1068,7 @@ func (r *FSRoot) Unlink(path string) int {
}
// Mkdir creates a new directory
func (r *FSRoot) Mkdir(path string, mode uint32) int {
func (r *NostrRoot) Mkdir(path string, mode uint32) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1109,7 +1110,7 @@ func (r *FSRoot) Mkdir(path string, mode uint32) int {
}
// Rmdir removes a directory
func (r *FSRoot) Rmdir(path string) int {
func (r *NostrRoot) Rmdir(path string) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1151,7 +1152,7 @@ func (r *FSRoot) Rmdir(path string) int {
}
// Utimens updates file timestamps
func (r *FSRoot) Utimens(path string, tmsp []fuse.Timespec) int {
func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT

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
},
},
},
}

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
},
}

View File

@@ -8,26 +8,36 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
var publish = &cli.Command{
Name: "publish",
Usage: "publishes a note with content from stdin",
Description: `reads content from stdin and publishes it as a note, optionally as a reply to another note.
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...
echo "tagged post" | nak publish -t t=mytag -t e=someeventid`,
nak publish -t t=mytag -t e=someeventid "tagged post"`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
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"},
@@ -40,11 +50,6 @@ example:
DefaultText: "now",
Value: nostr.Now(),
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "nevent",
Usage: "print the nevent code (to stderr) after the event is published",
@@ -57,9 +62,17 @@ example:
},
),
Action: func(ctx context.Context, c *cli.Command) error {
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
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{
@@ -74,48 +87,67 @@ example:
evt.CreatedAt = getNaturalDate(c, "created-at")
}
// handle reply flag
// 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 replyTo := c.String("reply"); replyTo != "" {
var replyEvent *nostr.Event
// try to decode as nevent or naddr first
if strings.HasPrefix(replyTo, "nevent1") || strings.HasPrefix(replyTo, "naddr1") {
_, value, err := nip19.Decode(replyTo)
if err != nil {
return fmt.Errorf("invalid reply target: %w", err)
}
switch pointer := value.(type) {
case nostr.EventPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
case nostr.EntityPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
}
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
} else {
// try as raw event ID
id, err := nostr.IDFromHex(replyTo)
if err != nil {
return fmt.Errorf("invalid event id: %w", err)
}
replyEvent, _, err = sys.FetchSpecificEvent(ctx, nostr.EventPointer{ID: id}, sdk.FetchSpecificEventParameters{})
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
if 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 replyEvent.Kind != 1 {
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
evt.Tags = append(evt.Tags,
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
nostr.Tag{"p", replyEvent.PubKey.Hex()},
)
// 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)
}
@@ -150,14 +182,9 @@ example:
relayUrls := sys.FetchWriteRelays(ctx, pk)
relayUrls = nostr.AppendUnique(relayUrls, targetRelays...)
relayUrls = nostr.AppendUnique(relayUrls, replyRelays...)
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
relays := connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},
)
relayUrls = nostr.AppendUnique(relayUrls, c.StringSlice("relay")...)
relays := connectToAllRelays(ctx, c, relayUrls)
if len(relays) == 0 {
if len(relayUrls) == 0 {
@@ -179,3 +206,102 @@ example:
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

@@ -24,13 +24,14 @@ var relay = &cli.Command{
info, err := nip11.Fetch(ctx, url)
if err != nil {
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %w", url, err)
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %s", url, err)
continue
}
pretty, _ := json.MarshalIndent(info, "", " ")
stdout(string(pretty))
}
exitIfLineProcessingError(ctx)
return nil
},
}

395
req.go
View File

@@ -15,9 +15,8 @@ import (
"fiatjaf.com/nostr/eventstore"
"fiatjaf.com/nostr/eventstore/slicestore"
"fiatjaf.com/nostr/eventstore/wrappers"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip77"
"github.com/fatih/color"
"fiatjaf.com/nostr/schema"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
"golang.org/x/sync/errgroup"
@@ -42,64 +41,68 @@ it can also take a filter from stdin, optionally modify it with flags and send i
example:
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "outbox",
Usage: "use outbox relays from specified public keys",
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
DefaultText: "false",
},
&cli.DurationFlag{
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "spell",
Usage: "output a spell event (kind 777) instead of a filter",
},
)...,
Flags: combineFlags([][]cli.Flag{reqFilterFlags},
&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`",
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "skip event signature verification from relays",
},
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&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: "spell",
Usage: "output a spell event (kind 777) instead of a filter",
},
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
if c.Bool("no-verify") {
sys.Pool.RelayOptions.AssumeValid = true
}
negentropy := c.Bool("ids-only") || c.IsSet("only-missing")
if negentropy {
if c.Bool("paginate") || c.Bool("stream") || c.Bool("outbox") {
@@ -125,32 +128,20 @@ example:
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
}
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 {
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
nostr.PoolOptions{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
},
})
)
// stop here already if all connections failed
if len(relays) == 0 {
@@ -195,8 +186,11 @@ example:
if err := easyjson.Unmarshal([]byte(scanner.Text()), &evt); err != nil {
continue
}
if err := store.SaveEvent(evt); err != nil || err == eventstore.ErrDupEvent {
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 {
@@ -206,6 +200,7 @@ example:
target := PrintingQuerierPublisher{
QuerierPublisher: wrappers.StorePublisher{Store: store, MaxLimit: math.MaxInt},
jq: jq,
}
var source nostr.Querier = nil
@@ -235,7 +230,9 @@ example:
}
}
} else {
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")
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 or spell
@@ -277,7 +274,8 @@ func performReq(
paginate bool,
paginateInterval time.Duration,
label string,
) {
jq jqProcessor,
) error {
var results chan nostr.RelayEvent
var closeds chan nostr.RelayClosed
@@ -291,6 +289,24 @@ func performReq(
} 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,
@@ -298,54 +314,80 @@ func performReq(
})
}
// 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
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()
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// new relay, add it
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()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
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,
})
mu.Unlock()
continue // done with this relay url
} else {
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
// otherwise we'll just use the idx
mu.Unlock()
}
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
return nil
})
return nil
})
}
errg.Wait()
}
errg.Wait()
if stream {
logverbose("running subscription with %d directed filters...\n", len(defs))
@@ -367,25 +409,44 @@ func performReq(
readevents:
for {
select {
case ie, ok := <-results:
if !ok {
case ie, stillOpen := <-results:
if !stillOpen {
break readevents
}
stdout(ie.Event)
case closed := <-closeds:
if closed.HandledAuth {
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
var out string
if jq == nil {
out = ie.Event.String()
} else {
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
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{
&PubKeySliceFlag{
&PubKeyOrAddressFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors",
@@ -397,10 +458,10 @@ var reqFilterFlags = []cli.Flag{
Usage: "only accept events with these ids",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
&KindSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Usage: "only accept events with these kind numbers or kind names",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
@@ -424,6 +485,11 @@ var reqFilterFlags = []cli.Flag{
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "h",
Usage: "shortcut for --tag h=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
@@ -447,38 +513,75 @@ var reqFilterFlags = []cli.Flag{
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 {
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = append(filter.Authors, authors...)
tags := make([]flagTag, 0, 5)
if as := getPubKeyOrAddressSlice(c, "author"); len(as) > 0 {
for _, author := range as {
// is it an address?
if author.Addr != nil {
tags = append(tags, flagTag{"a", author.Addr.AsTagReference()})
continue
}
// or is it an "author" pubkey?
if author.PubKey != nostr.ZeroPK {
filter.Authors = append(filter.Authors, author.PubKey)
}
}
}
if ids := getIDSlice(c, "id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
for _, kind64 := range c.IntSlice("kind") {
filter.Kinds = append(filter.Kinds, nostr.Kind(kind64))
if kinds := getKindSlice(c, "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.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
val := spl[1]
if len(spl[0]) == 1 {
val = decodeTagValue(val, rune(spl[0][0]))
}
tags = append(tags, flagTag{spl[0], val})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", decodeTagValue(etag)})
tags = append(tags, flagTag{"e", decodeTagValue(etag, 'e')})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", decodeTagValue(ptag)})
tags = append(tags, flagTag{"p", decodeTagValue(ptag, 'p')})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", decodeTagValue(dtag)})
tags = append(tags, flagTag{"d", dtag})
}
for _, htag := range c.StringSlice("h") {
tags = append(tags, flagTag{"h", htag})
}
if len(tags) > 0 && filter.Tags == nil {
@@ -486,10 +589,10 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
}
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
if _, ok := filter.Tags[tag.key]; !ok {
filter.Tags[tag.key] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
filter.Tags[tag.key] = append(filter.Tags[tag.key], tag.value)
}
if c.IsSet("since") {
@@ -510,11 +613,25 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
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 {
stdout(evt)
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

161
serve.go
View File

@@ -6,9 +6,12 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"time"
@@ -61,6 +64,14 @@ var serve = &cli.Command{
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{}
@@ -105,13 +116,72 @@ var serve = &cli.Command{
rl.Negentropy = true
}
started := make(chan bool)
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))
@@ -162,90 +232,63 @@ var serve = &cli.Command{
}
}
ln, err := net.Listen("tcp", net.JoinHostPort(hostname, strconv.Itoa(port)))
if err != nil {
return err
}
go func() {
err := rl.Start(hostname, port, started)
exited <- err
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 ")
}
log(" got %s%s %v\n", negentropy, color.HiYellowString("request"), colors.italic(filter))
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, ""
}
totalConnections := atomic.Int32{}
rl.OnConnect = func(ctx context.Context) {
totalConnections.Add(1)
go func() {
<-ctx.Done()
totalConnections.Add(-1)
}()
}
d := debounce.New(time.Second * 2)
printStatus = func() {
d(func() {
totalEvents, 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)),
)
})
}
<-started
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)

View File

@@ -23,7 +23,7 @@ var spell = &cli.Command{
Usage: "downloads a spell event and executes its REQ request",
ArgsUsage: "[nevent_code]",
Description: `fetches a spell event (kind 777) and executes REQ command encoded in its tags.`,
Flags: append(defaultKeyFlags,
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)",
@@ -82,19 +82,13 @@ var spell = &cli.Command{
displayName := entry.Name
if displayName == "" {
displayName = entry.Content
if len(displayName) > 28 {
displayName = displayName[:27] + "…"
}
displayName = clampWithEllipsis(entry.Content, 28)
}
if displayName != "" {
displayName = color.HiMagentaString(displayName) + ": "
}
desc := entry.Content
if len(desc) > 50 {
desc = desc[0:49] + "…"
}
desc := clampWithEllipsis(entry.Content, 50)
lastUsed := entry.LastUsed.Format("2006-01-02 15:04")
stdout(fmt.Sprintf(" %s %s%s - %s",
@@ -254,7 +248,9 @@ func runSpell(
// execute
logSpellDetails(spell)
performReq(ctx, spellFilter, spellRelays, stream, outbox, c.Uint("outbox-relays-per-pubkey"), false, 0, "nak-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
}
@@ -448,20 +444,13 @@ func logSpellDetails(spell nostr.Event) {
nameTag := spell.Tags.Find("name")
name := ""
if nameTag != nil {
name = nameTag[1]
if len(name) > 28 {
name = name[:27] + "…"
}
name = clampWithEllipsis(nameTag[1], 28)
}
if name != "" {
name = ": " + color.HiMagentaString(name)
}
desc := spell.Content
if len(desc) > 50 {
desc = desc[0:49] + "…"
}
desc := clampWithEllipsis(spell.Content, 50)
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
identifier := "spell" + idStr[len(idStr)-7:]

46
sync.go
View File

@@ -124,9 +124,27 @@ func getBoundKey(b negentropy.Bound) boundKey {
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),
@@ -141,13 +159,18 @@ func NewRelayThirdPartyRemote(ctx context.Context, url string) (*RelayThirdParty
}
switch env := envelope.(type) {
case *nip77.OpenEnvelope, *nip77.CloseEnvelope:
rtpr.err = fmt.Errorf("unexpected %s received from relay", env.Label())
rtpr.fail(fmt.Errorf("unexpected %s received from relay", env.Label()))
return
case *nip77.ErrorEnvelope:
rtpr.err = fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason)
rtpr.fail(fmt.Errorf("relay returned a %s: %s", env.Label(), env.Reason))
return
case *nip77.MessageEnvelope:
rtpr.messages <- env.Message
rtpr.mu.Lock()
failed := rtpr.err != nil
rtpr.mu.Unlock()
if !failed {
rtpr.messages <- env.Message
}
}
},
})
@@ -185,12 +208,13 @@ func (rtpr *RelayThirdPartyRemote) SendClose() error {
var thirdPartyRemoteEndOfMessages = errors.New("the-end")
func (rtpr *RelayThirdPartyRemote) Receive() (string, error) {
if rtpr.err != nil {
return "", rtpr.err
}
if msg, ok := <-rtpr.messages; ok {
return msg, nil
}
// channel closed by fail()
if err := rtpr.getErr(); err != nil {
return "", err
}
return "", thirdPartyRemoteEndOfMessages
}
@@ -204,6 +228,9 @@ func NewThirdPartyNegentropy(peerA, peerB *RelayThirdPartyRemote, filter nostr.F
}
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{})
@@ -341,7 +368,6 @@ func (n *ThirdPartyNegentropy) Run(ctx context.Context) error {
n.PeerA.SendClose()
n.PeerB.SendClose()
close(n.Deltas)
return nil
}
@@ -409,7 +435,7 @@ func parseMessageBuildNext(
skipCallback(getBoundKey(bound))
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, int(negentropy.SkipMode))
negentropy.WriteVarInt(nextMsg, uint64(negentropy.SkipMode))
}
case negentropy.FingerprintMode:
@@ -420,7 +446,7 @@ func parseMessageBuildNext(
if _, skipped := skippedBounds[getBoundKey(bound)]; !skipped {
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
nextMsg.Write(acc.Buf[0:negentropy.FingerprintSize] /* idem */)
}
case negentropy.IdListMode:
@@ -452,7 +478,7 @@ func parseMessageBuildNext(
fingerprint := acc.GetFingerprint(numIds)
bw.WriteBound(nextMsg, bound)
negentropy.WriteVarInt(nextMsg, int(negentropy.FingerprintMode))
negentropy.WriteVarInt(nextMsg, uint64(negentropy.FingerprintMode))
nextMsg.Write(fingerprint[:])
}
default:

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

@@ -4,6 +4,7 @@ import (
"context"
"fiatjaf.com/nostr"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -18,7 +19,8 @@ it outputs nothing if the verification is successful.`,
Action: func(ctx context.Context, c *cli.Command) error {
for stdinEvent := range getJsonsOrBlank() {
evt := nostr.Event{}
if stdinEvent == "" {
if stdinEvent == "{}" && !isPiped() {
// blank sentinel from getJsonsOrBlank(), use the argument instead
stdinEvent = c.Args().First()
if stdinEvent == "" {
continue
@@ -27,23 +29,35 @@ it outputs nothing if the verification is successful.`,
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
ctx = lineProcessingError(ctx, "invalid event: %s", err)
logverbose("<>: invalid event.\n", evt.ID.Hex())
logverbose("%s\n", color.RedString("<>: invalid event."))
continue
}
if evt.GetID() != evt.ID {
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
logverbose("%s: invalid id.\n", evt.ID.Hex())
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: invalid signature.\n", evt.ID.Hex())
logverbose("%s\n", color.RedString("invalid signature: %s", evt.ID.Hex()))
continue
}
logverbose("%s: valid.\n", evt.ID.Hex())
logverbose("%s\n", color.GreenString("valid: %s", evt.ID.Hex()))
}
exitIfLineProcessingError(ctx)

View File

@@ -28,7 +28,7 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
relays := sys.FetchOutboxRelays(ctx, pk, 3)
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
if w == nil {
return nil, nil, fmt.Errorf("error loading walle")
return nil, nil, fmt.Errorf("error loading wallet")
}
w.Processed = func(evt nostr.Event, err error) {
@@ -105,7 +105,7 @@ var wallet = &cli.Command{
Usage: "displays the current wallet balance",
Description: "all wallet data is stored on Nostr relays, signed and encrypted with the given key, and reloaded again from relays on every call.\n\nthe same data can be accessed by other compatible nip60 clients.",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
Flags: combineFlags([][]cli.Flag{},
&cli.BoolFlag{
Name: "stream",
Usage: "keep listening for wallet-related events and logging them",
@@ -139,7 +139,11 @@ var wallet = &cli.Command{
}
for _, url := range w.Mints {
stdout(strings.Split(url, "://")[1])
if _, host, ok := strings.Cut(url, "://"); ok {
stdout(host)
} else {
stdout(url)
}
}
closew()
@@ -195,7 +199,11 @@ var wallet = &cli.Command{
}
for _, token := range w.Tokens {
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
stdout(token.ID(), token.Proofs.Amount(), mintHost)
}
closew()
@@ -221,7 +229,11 @@ var wallet = &cli.Command{
for _, token := range w.Tokens {
if slices.Contains(ids, token.ID()) {
w.DropToken(ctx, token.ID())
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), mintHost)
}
}
@@ -358,6 +370,10 @@ var wallet = &cli.Command{
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "target",
Usage: "npub, nprofile, nevent or hex pubkey",
},
&cli.StringFlag{
Name: "mint",
Usage: "send from a specific mint",
@@ -368,9 +384,8 @@ var wallet = &cli.Command{
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) < 2 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
if c.Args().Len() < 1 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> --target <target>...")
}
w, closew, err := prepareWallet(ctx, c)
@@ -484,8 +499,13 @@ var wallet = &cli.Command{
} else {
return fmt.Errorf("missing --private-key")
}
} else if sk := c.String("private-key"); sk != "" && !c.Bool("force") {
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
} 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)
@@ -502,13 +522,18 @@ var wallet = &cli.Command{
info.ParseEvent(ie.Event)
}
if mints := c.StringSlice("mints"); len(mints) == 0 && len(info.Mints) == 0 {
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