Compare commits

...

107 Commits

Author SHA1 Message Date
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
44 changed files with 2009 additions and 677 deletions

View File

@@ -107,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==
@@ -145,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)"
@@ -284,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
@@ -343,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
@@ -353,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
@@ -472,3 +521,40 @@ gitnostr.com... ok.
~> 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

@@ -20,7 +20,7 @@ var blossomCmd = &cli.Command{
UseShortOptionHandling: true,
Usage: "an army knife for blossom things",
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
Flags: combineFlags([][]cli.Flag{},
&cli.StringSliceFlag{
Name: "server",
Aliases: []string{"s"},
@@ -179,7 +179,7 @@ var blossomCmd = &cli.Command{
hasError = true
continue
}
stdout(data)
os.Stdout.Write(data)
}
}
@@ -238,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
@@ -281,7 +286,7 @@ if any of the files are not found the command will fail, otherwise it will succe
return err
}
out, _ := json.Marshal(bd)
stdout(out)
stdout(string(out))
return nil
}
@@ -300,7 +305,7 @@ if any of the files are not found the command will fail, otherwise it will succe
continue
}
out, _ := json.Marshal(bd)
stdout(out)
stdout(string(out))
}
exitIfLineProcessingError(ctx)

234
bunker.go
View File

@@ -13,6 +13,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
@@ -44,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"},
@@ -185,7 +177,7 @@ var bunker = &cli.Command{
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 {
@@ -225,7 +217,7 @@ var bunker = &cli.Command{
}
}
}
relays := connectToAllRelays(ctx, c, allRelays, nil)
relays := connectToAllRelays(ctx, c, allRelays)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -244,12 +236,19 @@ 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() {
mu.Lock()
defer mu.Unlock()
iqs := make(url.Values)
maps.Copy(iqs, qs)
iqs.Set("secret", newSecret)
@@ -351,70 +350,15 @@ var bunker = &cli.Command{
signer := nip46.NewStaticKeySigner(sec)
signer.DefaultRelays = config.Relays
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
if persist != nil {
persist()
}
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
events <- event
}
}()
time.Sleep(time.Millisecond * 25)
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent through %s\n", res.Relay.URL)
} else {
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
}
}
}
}()
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
mu.Lock()
defer mu.Unlock()
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
@@ -450,60 +394,130 @@ 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()
go func() {
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
if errors.Is(err, nip46.AlreadyHandled) {
return
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
if errors.Is(err, nip46.AlreadyHandled) {
return
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
return
}
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := config.Relays
for _, c := range config.Clients {
if c.PubKey == from && len(c.CustomRelays) > 0 {
relays = c.CustomRelays
break
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := config.Relays
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)
}
}
// 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 {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
}
}()
}
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
mu.Lock()
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
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)
}
if persist != nil {
persist()
}
mu.Unlock()
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cancelPreviousBunkerInfoPrint = cancel
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
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

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

View File

@@ -7,10 +7,8 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
@@ -20,47 +18,16 @@ var count = &cli.Command{
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"COUNT\"",
Category: CATEGORY_SIGNER,
},
)...,
),
ArgsUsage: "[relay...]",
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 {
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
@@ -96,7 +63,13 @@ var count = &cli.Command{
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
relay, err := sys.Pool.EnsureRelay(relayUrl)
if err != nil {
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
fmt.Fprintf(os.Stderr, "error: %s\n", err)
continue
}
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})

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 {
@@ -130,5 +132,6 @@ func realCurl() error {
}
}
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

@@ -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)
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)

View File

@@ -8,6 +8,7 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
@@ -290,7 +291,7 @@ var encode = &cli.Command{
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
},
&cli.IntFlag{
&KindFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
@@ -305,12 +306,22 @@ 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 target := range getEncodeSubcommandInput(c.Args(), true) {
pubkey := getPubKey(c, "pubkey")
kind := nostr.Kind(c.Int("kind"))
kind := getKind(c, "kind")
d := c.String("identifier")
relays := c.StringSlice("relay")

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")

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{
@@ -88,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{
@@ -104,10 +111,10 @@ example:
DefaultText: "false, will only use manually-specified relays",
Category: CATEGORY_EXTRAS,
},
&cli.UintFlag{
&KindFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "event kind",
Usage: "event kind number or name",
DefaultText: "1",
Value: 0,
Category: CATEGORY_EVENT_FIELDS,
@@ -165,16 +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 {
relayUrls := c.Args().Slice()
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
@@ -184,7 +206,7 @@ example:
handleEvent := func(stdinEvent string) error {
evt.Content = ""
evt.CreatedAt = 0
clear(evt.Tags)
evt.Tags = evt.Tags[:0]
evt.ID = nostr.ZeroID
evt.PubKey = nostr.ZeroPK
evt.Sig = [64]byte{}
@@ -197,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
@@ -361,6 +383,9 @@ example:
}
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 {
@@ -385,10 +410,7 @@ example:
}
if len(relayUrls) > 0 {
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays = connectToAllRelays(ctx, c, relayUrls, nil)
relays = connectToAllRelays(ctx, c, relayUrls)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -397,15 +419,25 @@ example:
}
// print event as json
var result string
if c.Bool("envelope") {
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
result = string(j)
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 {
j, _ := easyjson.Marshal(&evt)
result = string(j)
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)
}

View File

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

View File

@@ -19,11 +19,15 @@ example:
nak filter '{"kind": 1, "content": "hello"}' '{"kinds": [1]}' -k 0
`,
DisableSliceFlagSeparator: true,
Flags: append(append([]cli.Flag{}, reqFilterFlags...),
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 {
@@ -59,7 +63,7 @@ example:
return err
}
jq, err := jqPrepare(c.String("jq"))
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
@@ -82,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
}
@@ -104,7 +111,7 @@ example:
if !matches {
continue
}
out, _ = json.MarshalToString(v)
out = v
}
stdout(out)
} else {

View File

@@ -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]
)
@@ -289,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)
}

2
fs.go
View File

@@ -24,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",

View File

@@ -23,7 +23,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",

View File

@@ -22,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",

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",
@@ -333,7 +332,7 @@ 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
}

35
git.go
View File

@@ -33,7 +33,6 @@ aside from those, there is also:
- 'nak git init' for setting up nip34 repository metadata; and
- 'nak git sync' for getting the latest metadata update from nostr relays (called automatically by other commands)
`,
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "init",
@@ -247,11 +246,12 @@ aside from those, there is also:
}
// override with flags and existing config
config.Identifier = getValue(existingConfig.Identifier, c.String("identifier"), config.Identifier)
// (identifier and ownerStr already hold the flag value, the interactive answer or the default)
config.Identifier = identifier
config.Name = getValue(existingConfig.Name, c.String("name"), config.Name)
config.Description = getValue(existingConfig.Description, c.String("description"), config.Description)
config.Web = getSliceValue(existingConfig.Web, c.StringSlice("web"), config.Web)
config.Owner = getValue(existingConfig.Owner, c.String("owner"), config.Owner)
config.Owner = ownerStr
config.GraspServers = getSliceValue(existingConfig.GraspServers, c.StringSlice("grasp-servers"), config.GraspServers)
config.EarliestUniqueCommit = getValue(existingConfig.EarliestUniqueCommit, c.String("earliest-unique-commit"), config.EarliestUniqueCommit)
maintainers := getSliceValue(existingConfig.Maintainers, c.StringSlice("maintainers"), config.Maintainers)
@@ -590,8 +590,8 @@ aside from those, there is also:
commitHash = ch
} else if ch, ok := info.Refs["refs/tags/"+ref]; ok {
commitHash = ch
} else if sr, ok := info.Symrefs[ref]; ok && ch != "" {
commitHash, _ = info.Refs[sr]
} else if sr, ok := info.Symrefs[ref]; ok {
commitHash = info.Refs[sr]
}
}
@@ -635,9 +635,10 @@ aside from those, there is also:
if outputPath == "-" {
if _, err = os.Stdout.Write(obj.Data); err != nil {
log("\nprinted object %s to stdout\n", color.CyanString(obj.Hash))
return err
}
log("\nprinted object %s to stdout\n", color.CyanString(obj.Hash))
return nil
}
if err := os.WriteFile(outputPath, obj.Data, 0644); err != nil {
@@ -854,7 +855,7 @@ aside from those, there is also:
}
// get the commit from state for the remote branch
if state.Event.ID == nostr.ZeroID {
if state == nil || state.Event.ID == nostr.ZeroID {
return fmt.Errorf("no repository state found")
}
@@ -1521,12 +1522,13 @@ please fix
log("failed to fetch repository announcement from relays: %s\n", err)
}
stateHEAD := ""
if state == nil {
stdout(color.YellowString("\n repository state not published."))
} else {
stateHEAD = state.Branches[state.HEAD]
}
stateHEAD, _ := state.Branches[state.HEAD]
stdout("\n" + color.CyanString("grasp status:"))
rows := make([][3]string, len(localConfig.GraspServers))
for s, server := range localConfig.GraspServers {
@@ -1554,7 +1556,13 @@ please fix
if commit == stateHEAD {
row[2] = color.GreenString("repository synced with state")
} else {
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", stateHEAD[0:5], commit[0:5])
short := func(s string) string {
if len(s) > 5 {
return s[0:5]
}
return s
}
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", short(stateHEAD), short(commit))
}
}
}
@@ -2865,5 +2873,10 @@ func rebuildGraspURLFromRemote(remoteName string) string {
}
func graspServerHost(s string) string {
return strings.SplitN(nostr.NormalizeURL(s), "/", 3)[2]
// NormalizeURL returns "" for empty or unparseable inputs
parts := strings.SplitN(nostr.NormalizeURL(s), "/", 3)
if len(parts) < 3 {
return ""
}
return parts[2]
}

4
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86
fiatjaf.com/nostr v0.0.0-20260625215907-7a78ad0c459f
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -29,8 +29,10 @@ require (
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
)

16
go.sum
View File

@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86 h1:p3HnX1UDT/CfiMvTc4yTcxHQm08ri7DM32P1uKkFNKg=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
fiatjaf.com/nostr v0.0.0-20260625215907-7a78ad0c459f h1:n0G6XmIXS/j+AgDRFiZqK+l1R0ZahlPe/HwHlfxwj/c=
fiatjaf.com/nostr v0.0.0-20260625215907-7a78ad0c459f/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=
@@ -102,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=
@@ -181,6 +183,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
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=
@@ -300,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=
@@ -308,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=
@@ -329,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.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=
@@ -341,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=

View File

@@ -16,7 +16,6 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip29"
"fiatjaf.com/nostr/nip42"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
@@ -28,25 +27,8 @@ var group = &cli.Command{
Description: `manage and interact with Nostr communities (NIP-29). Use "nak group <subcommand> <relay>'<identifier>" where host.tld is the relay and identifier is the group identifier.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "<subcommand> <relay>'<identifier> [flags]",
Flags: append(defaultKeyFlags,
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
),
Flags: combineFlags([][]cli.Flag{}),
Before: func(ctx context.Context, c *cli.Command) (context.Context, error) {
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
},
@@ -276,68 +258,32 @@ var group = &cli.Command{
return err
}
// stored events arrive newest-first before EOSE, so we buffer them
// from the end and print them in chronological order once EOSE
// arrives; live events are printed as they come.
eosed := false
var mu sync.Mutex
messages := make([]struct {
message string
rendered bool
}, 200)
messages := make([]string, 200)
base := len(messages)
// must be called with mu held
tryRender := func(i int) {
// if all messages before these are loaded we can render this,
// otherwise we render whatever we can and stop
for m, msg := range messages[base:] {
if msg.rendered {
continue
}
if msg.message == "" {
break
}
messages[base+m].rendered = true
stdout(msg.message)
}
}
for {
select {
case evt := <-sub.Events:
mu.Lock()
var i int
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
line := color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
if eosed {
i = len(messages)
messages = append(messages, struct {
message string
rendered bool
}{})
stdout(line)
} else if base > 0 {
base--
i = base
} else {
// pre-EOSE buffer is full (relay returned more than the limit); drop.
mu.Unlock()
continue
messages[base] = line
}
mu.Unlock()
go func() {
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
line := color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
mu.Lock()
messages[i].message = line
if eosed {
tryRender(i)
}
mu.Unlock()
}()
// else: pre-EOSE buffer is full (relay returned more than the limit); drop.
case reason := <-sub.ClosedReason:
stdout("closed:" + color.YellowString(reason))
case <-sub.EndOfStoredEvents:
mu.Lock()
eosed = true
tryRender(len(messages) - 1)
mu.Unlock()
for _, msg := range messages[base:] {
stdout(msg)
}
case <-sub.Context.Done():
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
}

View File

@@ -13,6 +13,7 @@ import (
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -21,11 +22,12 @@ import (
"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/v2"
"github.com/urfave/cli/v3"
@@ -185,7 +187,6 @@ 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
) []*nostr.Relay {
// first pass to check if these are valid relay URLs
for i, url := range relayUrls {
@@ -218,21 +219,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()
}()
@@ -242,7 +250,7 @@ func connectToAllRelays(
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2]))
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
relay := connectToSingleRelay(ctx, c, url, nil, log)
if relay != nil {
relays = append(relays, relay)
}
@@ -257,12 +265,11 @@ 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 c.Bool("force-pre-auth") {
if colorizepreamble != nil {
colorizepreamble(color.YellowString)
}
@@ -275,7 +282,7 @@ func connectToSingleRelay(
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
return authSigner(ctx, c, logthis, authEvent)
}); err == nil {
// auth succeeded
goto preauthSuccess
@@ -349,29 +356,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)
@@ -501,6 +485,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 {
@@ -510,8 +507,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 {
@@ -610,6 +607,68 @@ func clampWithEllipsis(s string, size int) string {
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
@@ -635,3 +694,17 @@ var colors = struct {
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/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) {

32
jq.go
View File

@@ -14,11 +14,16 @@ 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");
`
type jqProcessor func(nostr.Event) (any, bool, error)
// 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) (jqProcessor, error) {
func jqPrepare(expr string, raw bool) (jqProcessor, error) {
if expr == "" {
return nil, nil
}
@@ -33,30 +38,43 @@ func jqPrepare(expr string) (jqProcessor, error) {
return nil, fmt.Errorf("failed to compile jq expression: %w", err)
}
return func(evt nostr.Event) (any, bool, error) {
return func(evt nostr.Event) (string, bool, error) {
input, err := toJQInput(evt)
if err != nil {
return nil, false, err
return "", false, err
}
iter := code.Run(input)
for {
v, ok := iter.Next()
if !ok {
return v, false, nil
return "", false, nil
}
if err, ok := v.(error); ok {
return v, false, err
return "", false, err
}
if jqTruthy(v) {
return v, true, nil
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 {

24
key.go
View File

@@ -28,6 +28,7 @@ var key = &cli.Command{
decryptKey,
combine,
validate,
defaultCommand,
},
}
@@ -172,7 +173,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)
@@ -180,6 +182,7 @@ var decryptKey = &cli.Command{
}
stdout(sk.Hex())
}
exitIfLineProcessingError(ctx)
return nil
}
default:
@@ -211,7 +214,7 @@ Returns error if key is invalid, otherwise exits successfully.`,
ctx = lineProcessingError(ctx, "invalid npub: %s", err)
continue
}
tmp := data.([32]byte)
tmp := data.(nostr.PubKey)
pkBytes = tmp[:]
} else {
pkBytes, err = hex.DecodeString(pk)
@@ -235,6 +238,17 @@ Returns error if key is invalid, otherwise exits successfully.`,
},
}
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",
@@ -298,6 +312,12 @@ However, if the intent is to check if two existing Nostr pubkeys match a given c
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
},
}

View File

@@ -22,9 +22,10 @@ func setupLocalDatabases(c *cli.Command, sys *sdk.System) {
hintsPath := filepath.Join(configPath, "outbox/hints")
os.MkdirAll(hintsPath, 0755)
_, err := lmdbh.NewLMDBHints(hintsPath)
if err != nil {
if hdb, err := lmdbh.NewLMDBHints(hintsPath); err != nil {
log("failed to create lmdb hints db at '%s': %s\n", hintsPath, err)
} else {
sys.Hints = hdb
}
eventsPath := filepath.Join(configPath, "events")

134
main.go
View File

@@ -6,13 +6,53 @@ import (
"net/textproto"
"os"
"path/filepath"
"strings"
"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,
},
&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`)",
},
}
var (
version string = "debug"
isVerbose bool = false
@@ -33,6 +73,7 @@ var app = &cli.Command{
decode,
encode,
key,
kindCmd,
verify,
relay,
admin,
@@ -52,6 +93,7 @@ var app = &cli.Command{
nsite,
git,
group,
podcast,
nip,
syncCmd,
spell,
@@ -59,47 +101,51 @@ var app = &cli.Command{
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
},
},
&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
},
},
},
&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()
@@ -110,6 +156,18 @@ var app = &cli.Command{
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
},

9
mcp.go
View File

@@ -19,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",
@@ -157,6 +155,9 @@ var mcpServer = &cli.Command{
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name := required[string](r, "name")
limit, _ := optional[float64](r, "limit")
if limit == 0 {
limit = 10
}
res := strings.Builder{}
res.Grow(500)
@@ -241,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")

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

View File

@@ -5,9 +5,9 @@ import (
"sync/atomic"
"syscall"
"fiatjaf.com/nostr"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"fiatjaf.com/nostr"
)
type AsyncFile struct {

View File

@@ -23,7 +23,7 @@ var nsite = &cli.Command{
Usage: "publishes and downloads nip-5A static sites",
ArgsUsage: "<directory> [relay...]",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Flags: combineFlags([][]cli.Flag{}),
Commands: []*cli.Command{
{
Name: "upload",
@@ -31,6 +31,10 @@ var nsite = &cli.Command{
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",
@@ -40,6 +44,10 @@ var nsite = &cli.Command{
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",
@@ -54,6 +62,11 @@ var nsite = &cli.Command{
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"},
@@ -94,6 +107,7 @@ var nsite = &cli.Command{
Root: root,
Identifier: identifier,
Paths: make(map[string][32]byte),
Title: c.String("title"),
Description: c.String("description"),
Source: c.String("source"),
}
@@ -179,28 +193,43 @@ var nsite = &cli.Command{
}
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)
}
relayURLs := nostr.AppendUnique(sys.FetchWriteRelays(ctx, pk), c.Args().Slice()[1:]...)
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")
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(string, ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayURLs, nil)
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 == "" {
stdout(nip19.EncodeNpub(pk))
log(nip19.EncodeNpub(pk) + "\n")
} else {
stdout(nip5a.PubKeyToBase36(pk) + identifier)
log(nip5a.PubKeyToBase36(pk) + identifier + "\n")
}
return publishFlow(ctx, c, kr, evt, relays)

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
}

View File

@@ -3,6 +3,8 @@ package main
import (
"context"
stdjson "encoding/json"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
@@ -16,6 +18,24 @@ var profile = &cli.Command{
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()) {
@@ -27,6 +47,24 @@ example usage:
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))

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,51 +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{})
default:
return fmt.Errorf("unexpected reply target type: %T", value)
}
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
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
}
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)
}
@@ -153,12 +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()...)
relayUrls = nostr.AppendUnique(relayUrls, c.StringSlice("relay")...)
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}
relays := connectToAllRelays(ctx, c, relayUrls, nil)
relays := connectToAllRelays(ctx, c, relayUrls)
if len(relays) == 0 {
if len(relayUrls) == 0 {
@@ -180,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

@@ -31,6 +31,7 @@ var relay = &cli.Command{
pretty, _ := json.MarshalIndent(info, "", " ")
stdout(string(pretty))
}
exitIfLineProcessingError(ctx)
return nil
},
}

219
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,69 +41,61 @@ 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: "jq",
Usage: "filter returned events with jq expression",
},
&cli.BoolFlag{
Name: "no-verify",
Usage: "skip event signature verification from relays",
},
&cli.StringFlag{
Name: "only-missing",
Usage: "use nip77 negentropy to only fetch events that aren't present in the given jsonl file",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "outbox",
Usage: "read from \"write\" relays of \"authors\" and/or from the \"read\" relays of any \"#p\" or \"#P\" tags",
DefaultText: "false, will only use manually-specified relays",
},
&cli.UintFlag{
Name: "outbox-relays-per-pubkey",
Aliases: []string{"n"},
Usage: "number of outbox relays to use for each pubkey",
Value: 3,
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
DefaultText: "false",
},
&cli.DurationFlag{
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform 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 {
@@ -137,7 +128,7 @@ example:
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
}
jq, err := jqPrepare(c.String("jq"))
jq, err := jqPrepare(c.String("jq"), c.Bool("jq-raw"))
if err != nil {
return err
}
@@ -146,30 +137,10 @@ example:
}
if len(relayUrls) > 0 && !negentropy {
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(
nip42.GetRelayURLFromAuthEvent(*authEvent),
"wss://",
)
s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
}
log(s+"\n", args...)
}, authEvent)
}
relays := connectToAllRelays(
ctx,
c,
relayUrls,
forcePreAuthSigner,
)
// stop here already if all connections failed
@@ -362,21 +333,15 @@ func performReq(
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
mu.Lock()
if slices.IndexFunc(defs, matchUrl) == -1 {
// new relay, add it
mu.Lock()
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue
}
mu.Unlock()
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
}
mu.Unlock()
}
return nil
@@ -402,30 +367,20 @@ func performReq(
}
matchUrl := func(def nostr.DirectedFilter) bool { return def.Relay == url }
idx := slices.IndexFunc(defs, matchUrl)
if idx == -1 {
mu.Lock()
if idx := slices.IndexFunc(defs, matchUrl); idx == -1 {
// new relay, add it
mu.Lock()
// check again after locking to prevent races
idx = slices.IndexFunc(defs, matchUrl)
if idx == -1 {
// then add it
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
mu.Unlock()
continue // done with this relay url
}
// otherwise we'll just use the idx
mu.Unlock()
filter := filter.Clone()
filter.Authors = []nostr.PubKey{pubkey}
defs = append(defs, nostr.DirectedFilter{
Filter: filter,
Relay: url,
})
} else {
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
}
// existing relay, add this pubkey
defs[idx].Authors = append(defs[idx].Authors, pubkey)
mu.Unlock()
}
return nil
@@ -470,7 +425,7 @@ readevents:
if !matches {
continue
}
out, _ = json.MarshalToString(v)
out = v
}
stdout(out)
@@ -503,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{
@@ -558,6 +513,16 @@ 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 {
@@ -587,8 +552,8 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
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
@@ -664,7 +629,7 @@ func (p PrintingQuerierPublisher) Publish(ctx context.Context, evt nostr.Event)
if !matches {
return nil
}
out, _ = json.MarshalToString(v)
out = v
}
stdout(out)
return nil

View File

@@ -64,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{}
@@ -116,12 +124,19 @@ var serve = &cli.Command{
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() {
@@ -228,23 +243,47 @@ var serve = &cli.Command{
// 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, ""

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)",

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

View File

@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/schema"
@@ -22,29 +21,21 @@ nak event -k 1 -p not_a_pubkey | nak validate
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml",
TakesFile: true,
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 {
var validator schema.Validator
if schemaURL := c.String("schema"); strings.HasPrefix(schemaURL, "http") {
var err error
validator, err = schema.NewValidatorFromURL(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from '%s': %w", schemaURL, err)
}
} else {
var err error
validator, err = schema.NewValidatorFromFile(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from %s: %w", schemaURL, err)
}
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 {
@@ -56,12 +47,12 @@ nak event -k 1 -p not_a_pubkey | nak validate
}
stdout(evt)
return nil
}
for stdinEvent := range getJsonsOrBlank() {
if stdinEvent == "" {
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)

View File

@@ -19,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

View File

@@ -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",
@@ -499,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)
@@ -517,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